Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion clients/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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/`)

Expand Down
34 changes: 34 additions & 0 deletions clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<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 () => {
const user = userEvent.setup();
const onOpenApp = vi.fn();
Expand Down
48 changes: 44 additions & 4 deletions clients/web/src/components/screens/AppsScreen/AppsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState, type Ref } from "react";
import { useCallback, useEffect, useRef, useState, type Ref } from "react";
import {
ActionIcon,
Button,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -333,6 +341,7 @@ export function AppsScreen({
onOpenApp,
onCloseApp,
onError,
autoOpen = false,
}: AppsScreenProps) {
const { selectedAppName, formValues, search } = ui;
const [running, setRunning] = useState(false);
Expand Down Expand Up @@ -415,16 +424,17 @@ 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([]);
setAppLogsExpanded(true);
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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions clients/web/src/components/views/InspectorView/InspectorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -520,6 +521,7 @@ export interface InspectorViewProps {
}

export function InspectorView({
deepLink,
deepLinkStatus,
servers: serversInput,
serverListWritable = true,
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -1146,6 +1190,7 @@ export function InspectorView({
onOpenApp={onOpenApp}
onCloseApp={onCloseApp}
onError={onAppError}
autoOpen={Boolean(deepLink?.openApp && deepLink.autoOpen)}
/>
</ScreenStage>
<ScreenStage active={activeTab === "Prompts"}>
Expand Down
Loading