Merge pull request #2166 from modelcontextprotocol/v2/chore/2156-use-mcp-apps
chore(web): extract App.tsx's MCP Apps wiring into useMcpApps
This commit is contained in:
commit
95db736a55
3 changed files with 646 additions and 160 deletions
|
|
@ -5,7 +5,6 @@ import {
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Box, Text } from "@mantine/core";
|
import { Box, Text } from "@mantine/core";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
@ -14,7 +13,6 @@ import type {
|
||||||
ElicitResult,
|
ElicitResult,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
Resource,
|
|
||||||
Tool,
|
Tool,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
|
@ -77,6 +75,7 @@ import {
|
||||||
type SetupClientForServer,
|
type SetupClientForServer,
|
||||||
} from "./hooks/useOAuthRecovery";
|
} from "./hooks/useOAuthRecovery";
|
||||||
import { useInspectorStores } from "./hooks/useInspectorStores";
|
import { useInspectorStores } from "./hooks/useInspectorStores";
|
||||||
|
import { useMcpApps } from "./hooks/useMcpApps";
|
||||||
import { useExportActions } from "./hooks/useExportActions";
|
import { useExportActions } from "./hooks/useExportActions";
|
||||||
import { useProgressToasts } from "./hooks/useProgressToasts";
|
import { useProgressToasts } from "./hooks/useProgressToasts";
|
||||||
import { useTaskToasts } from "./hooks/useTaskToasts";
|
import { useTaskToasts } from "./hooks/useTaskToasts";
|
||||||
|
|
@ -92,14 +91,7 @@ import type {
|
||||||
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
||||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||||
import { clearScrollMemory } from "./hooks/useScrollMemory";
|
import { clearScrollMemory } from "./hooks/useScrollMemory";
|
||||||
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
|
|
||||||
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
|
||||||
import { publishAppDocument } from "./lib/publishAppDocument";
|
|
||||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||||
import {
|
|
||||||
AppElicitationController,
|
|
||||||
type AppElicitationSession,
|
|
||||||
} from "./lib/appElicitationController";
|
|
||||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||||
import {
|
import {
|
||||||
ServerConfigModal,
|
ServerConfigModal,
|
||||||
|
|
@ -251,12 +243,6 @@ function App() {
|
||||||
const [inspectorClient, setInspectorClient] =
|
const [inspectorClient, setInspectorClient] =
|
||||||
useState<InspectorClient | null>(null);
|
useState<InspectorClient | null>(null);
|
||||||
|
|
||||||
// MCP Apps runtime wiring. `sandboxUrl` is the inspector's sandbox-proxy page
|
|
||||||
// (the trusted outer iframe); `appRendererRef` lets the app handlers push tool
|
|
||||||
// input/result into the running app and tear it down. The bridge factory wraps
|
|
||||||
// the active client's underlying SDK client so the running view can call the
|
|
||||||
// server, and reads the tool's UI resource into the sandbox on handshake.
|
|
||||||
const appRendererRef = useRef<AppRendererHandle>(null);
|
|
||||||
const configBaseUrl =
|
const configBaseUrl =
|
||||||
typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||||
// One `GET /api/config` fetch recovers every static payload field the app
|
// One `GET /api/config` fetch recovers every static payload field the app
|
||||||
|
|
@ -296,81 +282,6 @@ function App() {
|
||||||
});
|
});
|
||||||
}, [configBaseUrl]);
|
}, [configBaseUrl]);
|
||||||
|
|
||||||
// The `resources/list` entries, read lazily by the App bridge factories below.
|
|
||||||
// ext-apps treats a listing entry's `_meta.ui` as the static default for its
|
|
||||||
// UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP
|
|
||||||
// has to be able to see it. A ref rather than a dependency: `resources` is
|
|
||||||
// derived further down this component, and the factories only read it inside
|
|
||||||
// an async sandboxready handler, long after any render that produced it.
|
|
||||||
const listedResourcesRef = useRef<Resource[]>([]);
|
|
||||||
// Best-effort by construction: this reads the list as it stands when the app
|
|
||||||
// opens, and does not distinguish "no entry for this URI" from "the list
|
|
||||||
// hasn't arrived yet". Both yield no hints, which is the same outcome as
|
|
||||||
// having no listing carrier at all. Blocking the render on list readiness
|
|
||||||
// instead would mean waiting on a request that, for a server advertising no
|
|
||||||
// `resources` capability or whose list errored, never resolves — trading a
|
|
||||||
// missing default for an app that never renders.
|
|
||||||
const getListedResourceMeta = useCallback(
|
|
||||||
(uri: string) =>
|
|
||||||
listedResourcesRef.current.find((r) => r.uri === uri)?._meta,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// `_meta.ui.domain` support (#2056): hand a wrapped app document to the
|
|
||||||
// backend so it can serve it from a dedicated origin, giving the app's
|
|
||||||
// requests a real `Origin`. Resolves `null` on any backend that can't, and
|
|
||||||
// the factory then renders the app the default (opaque-origin) way.
|
|
||||||
const publishDocument = useCallback(
|
|
||||||
(doc: { html: string; csp?: string }) =>
|
|
||||||
publishAppDocument(doc, {
|
|
||||||
baseUrl: configBaseUrl,
|
|
||||||
authToken: getAuthToken(),
|
|
||||||
}),
|
|
||||||
[configBaseUrl],
|
|
||||||
);
|
|
||||||
|
|
||||||
const sandboxBridgeFactory = useMemo(
|
|
||||||
() =>
|
|
||||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
|
||||||
createAppBridgeFactory({
|
|
||||||
publishAppDocument: publishDocument,
|
|
||||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
|
||||||
getListedResourceMeta,
|
|
||||||
readResource: async (uri) => {
|
|
||||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
|
||||||
const invocation = await inspectorClient.readResource(uri);
|
|
||||||
return invocation.result;
|
|
||||||
},
|
|
||||||
// The bridge's sandboxready handler reads + posts the UI resource
|
|
||||||
// inside a detached async block; without this hook a 404 / malformed
|
|
||||||
// resource is console.error-only and the user stares at a blank
|
|
||||||
// frame. Surface it as a toast. The renderer separately drives
|
|
||||||
// `data-app-status` so an automated driver can time out on
|
|
||||||
// never-reaching-"ready" and read the toast.
|
|
||||||
onResourceError: (err) => {
|
|
||||||
notifications.show({
|
|
||||||
title: "App resource failed to load",
|
|
||||||
message: err.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
|
||||||
);
|
|
||||||
|
|
||||||
// App-rendered form elicitations (#1854). The controller is created once and
|
|
||||||
// handed to every InspectorClient at construction — its `render` is what opts
|
|
||||||
// this client into advertising the nested MCP Apps `elicitation` capability,
|
|
||||||
// which is why only the web client (the one with a sandbox) claims it.
|
|
||||||
const appElicitationControllerRef = useRef<AppElicitationController>(null);
|
|
||||||
appElicitationControllerRef.current ??= new AppElicitationController();
|
|
||||||
const appElicitationController = appElicitationControllerRef.current;
|
|
||||||
// The window onto the controller for the CURRENT client. Closing it when the
|
|
||||||
// client is replaced both rejects that connection's queued requests and
|
|
||||||
// refuses any it enqueues during its own (asynchronous) teardown — a late
|
|
||||||
// entry would otherwise be rendered by a factory bound to the replacement
|
|
||||||
// client, i.e. read and answered through a different server.
|
|
||||||
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
|
|
||||||
// `setupClientForServer` is synchronous and memoized, so a caller that
|
// `setupClientForServer` is synchronous and memoized, so a caller that
|
||||||
// awaited the config would still resume with the `sandboxUrl` captured by the
|
// awaited the config would still resume with the `sandboxUrl` captured by the
|
||||||
// render it STARTED in — undefined, on the very load this matters for. The
|
// render it STARTED in — undefined, on the very load this matters for. The
|
||||||
|
|
@ -398,66 +309,6 @@ function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
|
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
|
||||||
}, [initialConfigLoading]);
|
}, [initialConfigLoading]);
|
||||||
const appElicitations = useSyncExternalStore(
|
|
||||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
|
||||||
appElicitationController.subscribe,
|
|
||||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
|
||||||
appElicitationController.getEntries,
|
|
||||||
);
|
|
||||||
// A SECOND factory, differing from `sandboxBridgeFactory` only in that it
|
|
||||||
// advertises `hostCapabilities.elicitation`. An App-tool frame is never handed
|
|
||||||
// an elicitation, so telling those apps otherwise would be a false claim.
|
|
||||||
const elicitationBridgeFactory = useMemo(
|
|
||||||
() =>
|
|
||||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
|
||||||
createAppBridgeFactory({
|
|
||||||
advertiseElicitation: true,
|
|
||||||
publishAppDocument: publishDocument,
|
|
||||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
|
||||||
getListedResourceMeta,
|
|
||||||
readResource: async (uri) => {
|
|
||||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
|
||||||
const invocation = await inspectorClient.readResource(uri);
|
|
||||||
return invocation.result;
|
|
||||||
},
|
|
||||||
// Unlike the Apps tab there is no persistent surface to show the
|
|
||||||
// failure on — the modal is about to be replaced by the native form —
|
|
||||||
// so the toast is the only place the user learns why.
|
|
||||||
onResourceError: (err) => {
|
|
||||||
notifications.show({
|
|
||||||
title: "Elicitation app failed to load",
|
|
||||||
message: err.message,
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
|
||||||
);
|
|
||||||
/**
|
|
||||||
* Close the previous client's session and open one for the client being
|
|
||||||
* constructed. Synchronous, and called at construction, so the swap itself is
|
|
||||||
* the moment ownership changes hands.
|
|
||||||
*/
|
|
||||||
const newAppElicitationSession = useCallback(() => {
|
|
||||||
appElicitationSessionRef.current?.close(
|
|
||||||
new Error("Connection replaced before the app answered"),
|
|
||||||
);
|
|
||||||
const session = appElicitationController.openSession();
|
|
||||||
appElicitationSessionRef.current = session;
|
|
||||||
return session;
|
|
||||||
}, [appElicitationController]);
|
|
||||||
const handleAppElicitationSettle = useCallback(
|
|
||||||
(requestId: string, result: ElicitResult) => {
|
|
||||||
appElicitationController.settle(requestId, result);
|
|
||||||
},
|
|
||||||
[appElicitationController],
|
|
||||||
);
|
|
||||||
const handleAppElicitationFail = useCallback(
|
|
||||||
(requestId: string, error: Error) => {
|
|
||||||
appElicitationController.fail(requestId, error);
|
|
||||||
},
|
|
||||||
[appElicitationController],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Optimistic log level — `logging/setLevel` has no echo notification, so
|
// Optimistic log level — `logging/setLevel` has no echo notification, so
|
||||||
// the parent keeps the current value locally.
|
// the parent keeps the current value locally.
|
||||||
|
|
@ -695,15 +546,20 @@ function App() {
|
||||||
const tools = toolsPagination.items;
|
const tools = toolsPagination.items;
|
||||||
const prompts = promptsPagination.items;
|
const prompts = promptsPagination.items;
|
||||||
const resources = resourcesPagination.items;
|
const resources = resourcesPagination.items;
|
||||||
// Whatever the resource list currently holds — every page in the default
|
|
||||||
// aggregate mode, only the pages fetched so far under `paginatedLists`. The
|
// MCP Apps runtime wiring (#2156): the sandbox bridge factories, the renderer
|
||||||
// listing is a documented *default* that a read content item overrides, and
|
// handle, and the app-rendered elicitation controller. Called here rather
|
||||||
// an app whose entry sits on an unfetched page simply falls back to no hints
|
// than beside the other client-scoped hooks above because it reads the
|
||||||
// (`connect-src 'none'`), exactly as before this wiring existed. Walking the
|
// resource listing, which is only derived at this point.
|
||||||
// whole list to close that would issue the very requests the user opted out
|
const {
|
||||||
// of by turning pagination on, so the setting wins.
|
appRendererRef,
|
||||||
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
sandboxBridgeFactory,
|
||||||
listedResourcesRef.current = resources;
|
elicitationBridgeFactory,
|
||||||
|
appElicitations,
|
||||||
|
newAppElicitationSession,
|
||||||
|
handleAppElicitationSettle,
|
||||||
|
handleAppElicitationFail,
|
||||||
|
} = useMcpApps({ inspectorClient, configBaseUrl, resources });
|
||||||
|
|
||||||
// Fold the transport errors the SDK throws rather than delivers (e.g. -32601
|
// Fold the transport errors the SDK throws rather than delivers (e.g. -32601
|
||||||
// on HTTP 404) onto their still-pending Protocol requests, by correlating with
|
// on HTTP 404) onto their still-pending Protocol requests, by correlating with
|
||||||
|
|
@ -1882,12 +1738,13 @@ function App() {
|
||||||
onAppError,
|
onAppError,
|
||||||
activeServerId,
|
activeServerId,
|
||||||
handleCommandScopedAuthRecovery,
|
handleCommandScopedAuthRecovery,
|
||||||
|
appRendererRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onCloseApp = useCallback(() => {
|
const onCloseApp = useCallback(() => {
|
||||||
void appRendererRef.current?.teardown();
|
void appRendererRef.current?.teardown();
|
||||||
}, []);
|
}, [appRendererRef]);
|
||||||
|
|
||||||
const onGetPrompt = useCallback(
|
const onGetPrompt = useCallback(
|
||||||
async (name: string, args: Record<string, string>) => {
|
async (name: string, args: Record<string, string>) => {
|
||||||
|
|
|
||||||
398
clients/web/src/hooks/useMcpApps.test.tsx
Normal file
398
clients/web/src/hooks/useMcpApps.test.tsx
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act } from "react";
|
||||||
|
import type { Resource } from "@modelcontextprotocol/client";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import type { AppElicitationRequest } from "@inspector/core/mcp/appElicitation.js";
|
||||||
|
import { renderWithMantine } from "../test/renderWithMantine";
|
||||||
|
import type { AppBridgeFactoryDeps } from "../components/elements/AppRenderer/createAppBridgeFactory";
|
||||||
|
import { useMcpApps, type McpApps } from "./useMcpApps";
|
||||||
|
|
||||||
|
const { notificationsMock, createFactoryMock, publishMock } = vi.hoisted(
|
||||||
|
() => ({
|
||||||
|
notificationsMock: { show: vi.fn() },
|
||||||
|
createFactoryMock: vi.fn(),
|
||||||
|
publishMock: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: notificationsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The two bridge factories are what this hook is mostly *for*, and everything
|
||||||
|
// interesting about them is in the deps object it hands `createAppBridgeFactory`
|
||||||
|
// — which resource meta a URI resolves to, which client `readResource` reaches,
|
||||||
|
// which toast an error raises. Standing in for the real factory is what makes
|
||||||
|
// those readable: the real one needs a live sandbox iframe and an SDK client
|
||||||
|
// before it will even return, and would answer none of these questions.
|
||||||
|
vi.mock("../components/elements/AppRenderer/createAppBridgeFactory", () => ({
|
||||||
|
createAppBridgeFactory: createFactoryMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/publishAppDocument", () => ({
|
||||||
|
publishAppDocument: publishMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/authToken", () => ({
|
||||||
|
getAuthToken: () => "test-token",
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A distinct client identity per call. `Object.create` does not run the
|
||||||
|
* constructor, so this is a real `InspectorClient` prototype chain with no
|
||||||
|
* transport behind it — enough for a hook that only stores the reference and
|
||||||
|
* calls the two methods stubbed here.
|
||||||
|
*/
|
||||||
|
const client = (over: Record<string, unknown> = {}): InspectorClient =>
|
||||||
|
Object.assign(
|
||||||
|
Object.create(InspectorClient.prototype),
|
||||||
|
over,
|
||||||
|
) as InspectorClient;
|
||||||
|
|
||||||
|
const resource = (uri: string, meta?: Record<string, unknown>): Resource => ({
|
||||||
|
uri,
|
||||||
|
name: uri,
|
||||||
|
...(meta && { _meta: meta }),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
hook: () => McpApps;
|
||||||
|
rerender: (next: {
|
||||||
|
inspectorClient?: InspectorClient | null;
|
||||||
|
configBaseUrl?: string;
|
||||||
|
resources?: Resource[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
initial: {
|
||||||
|
inspectorClient?: InspectorClient | null;
|
||||||
|
configBaseUrl?: string;
|
||||||
|
resources?: Resource[];
|
||||||
|
} = {},
|
||||||
|
): Harness {
|
||||||
|
let latest: McpApps | undefined;
|
||||||
|
const props = (over: typeof initial) => ({
|
||||||
|
inspectorClient: null,
|
||||||
|
configBaseUrl: "http://localhost:6274",
|
||||||
|
resources: [] as Resource[],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
function Probe(p: Required<typeof initial>) {
|
||||||
|
latest = useMcpApps(p);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { rerender } = renderWithMantine(<Probe {...props(initial)} />);
|
||||||
|
return {
|
||||||
|
hook: () => {
|
||||||
|
if (!latest) throw new Error("hook did not render");
|
||||||
|
return latest;
|
||||||
|
},
|
||||||
|
rerender: (next) => {
|
||||||
|
act(() => {
|
||||||
|
rerender(<Probe {...props(next)} />);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deps the Nth `createAppBridgeFactory` call was made with. */
|
||||||
|
const depsOf = (call: number): AppBridgeFactoryDeps =>
|
||||||
|
createFactoryMock.mock.calls[call]?.[0] as AppBridgeFactoryDeps;
|
||||||
|
|
||||||
|
/** The most recent sandbox (non-elicitation) and elicitation dep objects. */
|
||||||
|
function latestDeps(): {
|
||||||
|
sandbox: AppBridgeFactoryDeps;
|
||||||
|
elicitation: AppBridgeFactoryDeps;
|
||||||
|
} {
|
||||||
|
const all = createFactoryMock.mock.calls.map(
|
||||||
|
(c) => c[0] as AppBridgeFactoryDeps,
|
||||||
|
);
|
||||||
|
const sandbox = all.filter((d) => !d.advertiseElicitation).at(-1);
|
||||||
|
const elicitation = all.filter((d) => d.advertiseElicitation).at(-1);
|
||||||
|
if (!sandbox || !elicitation) throw new Error("factories were not built");
|
||||||
|
return { sandbox, elicitation };
|
||||||
|
}
|
||||||
|
|
||||||
|
const elicitationRequest = (
|
||||||
|
requestId: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): AppElicitationRequest => ({
|
||||||
|
requestId,
|
||||||
|
resourceUri: "ui://demo/form.html",
|
||||||
|
params: {
|
||||||
|
message: "pick one",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { choice: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useMcpApps", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
notificationsMock.show.mockReset();
|
||||||
|
publishMock.mockReset();
|
||||||
|
createFactoryMock.mockReset();
|
||||||
|
// A fresh identity per call so a rebuilt factory is observable.
|
||||||
|
createFactoryMock.mockImplementation(() => vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds one sandbox factory and one elicitation factory", () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(createFactoryMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(depsOf(0).advertiseElicitation).toBeUndefined();
|
||||||
|
expect(depsOf(1).advertiseElicitation).toBe(true);
|
||||||
|
expect(h.hook().sandboxBridgeFactory).not.toBe(
|
||||||
|
h.hook().elicitationBridgeFactory,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps both factory identities stable across an unrelated re-render", () => {
|
||||||
|
const c = client();
|
||||||
|
const h = harness({ inspectorClient: c });
|
||||||
|
const first = h.hook();
|
||||||
|
h.rerender({ inspectorClient: c, resources: [resource("ui://a")] });
|
||||||
|
expect(h.hook().sandboxBridgeFactory).toBe(first.sandboxBridgeFactory);
|
||||||
|
expect(h.hook().elicitationBridgeFactory).toBe(
|
||||||
|
first.elicitationBridgeFactory,
|
||||||
|
);
|
||||||
|
expect(createFactoryMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds both factories when the client is replaced", () => {
|
||||||
|
const h = harness({ inspectorClient: client() });
|
||||||
|
const first = h.hook();
|
||||||
|
h.rerender({ inspectorClient: client() });
|
||||||
|
expect(h.hook().sandboxBridgeFactory).not.toBe(first.sandboxBridgeFactory);
|
||||||
|
expect(h.hook().elicitationBridgeFactory).not.toBe(
|
||||||
|
first.elicitationBridgeFactory,
|
||||||
|
);
|
||||||
|
expect(createFactoryMock).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves listed resource meta against the latest listing", () => {
|
||||||
|
const h = harness({ resources: [resource("ui://a", { ui: { csp: {} } })] });
|
||||||
|
expect(latestDeps().sandbox.getListedResourceMeta?.("ui://a")).toEqual({
|
||||||
|
ui: { csp: {} },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
latestDeps().sandbox.getListedResourceMeta?.("ui://b"),
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
// The listing is read through a ref, so a new page arriving is visible to
|
||||||
|
// the factory that was built before it without rebuilding the factory.
|
||||||
|
h.rerender({ resources: [resource("ui://b", { ui: {} })] });
|
||||||
|
expect(latestDeps().sandbox.getListedResourceMeta?.("ui://b")).toEqual({
|
||||||
|
ui: {},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
latestDeps().sandbox.getListedResourceMeta?.("ui://a"),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined meta for a listing entry that carries none", () => {
|
||||||
|
harness({ resources: [resource("ui://a")] });
|
||||||
|
expect(
|
||||||
|
latestDeps().sandbox.getListedResourceMeta?.("ui://a"),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getClient", () => {
|
||||||
|
it("hands the live client's app-renderer client to both factories", () => {
|
||||||
|
const inner = { id: "sdk" };
|
||||||
|
const c = client({ getAppRendererClient: () => inner });
|
||||||
|
harness({ inspectorClient: c });
|
||||||
|
expect(latestDeps().sandbox.getClient()).toBe(inner);
|
||||||
|
expect(latestDeps().elicitation.getClient()).toBe(inner);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null while disconnected", () => {
|
||||||
|
harness();
|
||||||
|
expect(latestDeps().sandbox.getClient()).toBeNull();
|
||||||
|
expect(latestDeps().elicitation.getClient()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is null when the client has no app-renderer client", () => {
|
||||||
|
const c = client({ getAppRendererClient: () => undefined });
|
||||||
|
harness({ inspectorClient: c });
|
||||||
|
expect(latestDeps().sandbox.getClient()).toBeNull();
|
||||||
|
expect(latestDeps().elicitation.getClient()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("readResource", () => {
|
||||||
|
it("returns the invocation result for both factories", async () => {
|
||||||
|
const result = { contents: [] };
|
||||||
|
const c = client({
|
||||||
|
readResource: vi.fn().mockResolvedValue({ result }),
|
||||||
|
});
|
||||||
|
harness({ inspectorClient: c });
|
||||||
|
await expect(latestDeps().sandbox.readResource("ui://a")).resolves.toBe(
|
||||||
|
result,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
latestDeps().elicitation.readResource("ui://a"),
|
||||||
|
).resolves.toBe(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects while disconnected", async () => {
|
||||||
|
harness();
|
||||||
|
await expect(latestDeps().sandbox.readResource("ui://a")).rejects.toThrow(
|
||||||
|
"No MCP client connected.",
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
latestDeps().elicitation.readResource("ui://a"),
|
||||||
|
).rejects.toThrow("No MCP client connected.");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onResourceError", () => {
|
||||||
|
it("names the Apps tab for the sandbox factory", () => {
|
||||||
|
harness();
|
||||||
|
latestDeps().sandbox.onResourceError?.(new Error("404"));
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith({
|
||||||
|
title: "App resource failed to load",
|
||||||
|
message: "404",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the elicitation modal for the elicitation factory", () => {
|
||||||
|
harness();
|
||||||
|
latestDeps().elicitation.onResourceError?.(new Error("boom"));
|
||||||
|
expect(notificationsMock.show).toHaveBeenCalledWith({
|
||||||
|
title: "Elicitation app failed to load",
|
||||||
|
message: "boom",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("publishAppDocument", () => {
|
||||||
|
it("posts the document against the configured base URL", async () => {
|
||||||
|
publishMock.mockResolvedValue("http://127.0.0.1:6278/app-document/x");
|
||||||
|
harness({ configBaseUrl: "http://127.0.0.1:6274" });
|
||||||
|
const doc = { html: "<p>hi</p>", csp: "default-src 'none'" };
|
||||||
|
await expect(
|
||||||
|
latestDeps().sandbox.publishAppDocument?.(doc),
|
||||||
|
).resolves.toBe("http://127.0.0.1:6278/app-document/x");
|
||||||
|
expect(publishMock).toHaveBeenCalledWith(doc, {
|
||||||
|
baseUrl: "http://127.0.0.1:6274",
|
||||||
|
authToken: "test-token",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds the factories when the base URL changes", () => {
|
||||||
|
const h = harness({ configBaseUrl: "http://a" });
|
||||||
|
const first = h.hook().sandboxBridgeFactory;
|
||||||
|
h.rerender({ configBaseUrl: "http://b" });
|
||||||
|
expect(h.hook().sandboxBridgeFactory).not.toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("app elicitations", () => {
|
||||||
|
it("starts with no entries and a stable handler identity", () => {
|
||||||
|
const h = harness();
|
||||||
|
const first = h.hook();
|
||||||
|
expect(first.appElicitations).toEqual([]);
|
||||||
|
h.rerender({ resources: [resource("ui://a")] });
|
||||||
|
expect(h.hook().newAppElicitationSession).toBe(
|
||||||
|
first.newAppElicitationSession,
|
||||||
|
);
|
||||||
|
expect(h.hook().handleAppElicitationSettle).toBe(
|
||||||
|
first.handleAppElicitationSettle,
|
||||||
|
);
|
||||||
|
expect(h.hook().handleAppElicitationFail).toBe(
|
||||||
|
first.handleAppElicitationFail,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a queued request and settles it with the app's result", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const session = h.hook().newAppElicitationSession();
|
||||||
|
const controller = new AbortController();
|
||||||
|
let pending!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
pending = session.render(elicitationRequest("r1", controller.signal));
|
||||||
|
});
|
||||||
|
expect(h.hook().appElicitations).toHaveLength(1);
|
||||||
|
expect(h.hook().appElicitations[0]?.requestId).toBe("r1");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
h.hook().handleAppElicitationSettle("r1", { action: "accept" });
|
||||||
|
});
|
||||||
|
await expect(pending).resolves.toEqual({ action: "accept" });
|
||||||
|
expect(h.hook().appElicitations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a request back to the native form", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const session = h.hook().newAppElicitationSession();
|
||||||
|
const controller = new AbortController();
|
||||||
|
let pending!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
pending = session.render(elicitationRequest("r1", controller.signal));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
h.hook().handleAppElicitationFail("r1", new Error("app died"));
|
||||||
|
});
|
||||||
|
await expect(pending).rejects.toThrow("app died");
|
||||||
|
expect(h.hook().appElicitations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the previous session when a new client is constructed", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const first = h.hook().newAppElicitationSession();
|
||||||
|
const controller = new AbortController();
|
||||||
|
let pending!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
pending = first.render(elicitationRequest("r1", controller.signal));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
h.hook().newAppElicitationSession();
|
||||||
|
});
|
||||||
|
await expect(pending).rejects.toThrow(
|
||||||
|
"Connection replaced before the app answered",
|
||||||
|
);
|
||||||
|
expect(h.hook().appElicitations).toEqual([]);
|
||||||
|
|
||||||
|
// And the closed session refuses anything it enqueues afterwards — the
|
||||||
|
// late entry a disconnecting client can still produce.
|
||||||
|
await expect(
|
||||||
|
first.render(elicitationRequest("r2", controller.signal)),
|
||||||
|
).rejects.toThrow("App elicitation session is closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the first session with nothing to close", () => {
|
||||||
|
const h = harness();
|
||||||
|
expect(() => h.hook().newAppElicitationSession()).not.toThrow();
|
||||||
|
expect(h.hook().appElicitations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps one controller across re-renders", async () => {
|
||||||
|
const h = harness();
|
||||||
|
const session = h.hook().newAppElicitationSession();
|
||||||
|
const controller = new AbortController();
|
||||||
|
let pending!: Promise<unknown>;
|
||||||
|
act(() => {
|
||||||
|
pending = session.render(elicitationRequest("r1", controller.signal));
|
||||||
|
});
|
||||||
|
h.rerender({ inspectorClient: client() });
|
||||||
|
expect(h.hook().appElicitations).toHaveLength(1);
|
||||||
|
act(() => {
|
||||||
|
h.hook().handleAppElicitationSettle("r1", { action: "cancel" });
|
||||||
|
});
|
||||||
|
await expect(pending).resolves.toEqual({ action: "cancel" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a renderer ref that starts empty and stays identical", () => {
|
||||||
|
const h = harness();
|
||||||
|
const ref = h.hook().appRendererRef;
|
||||||
|
expect(ref.current).toBeNull();
|
||||||
|
h.rerender({ inspectorClient: client() });
|
||||||
|
expect(h.hook().appRendererRef).toBe(ref);
|
||||||
|
});
|
||||||
|
});
|
||||||
231
clients/web/src/hooks/useMcpApps.ts
Normal file
231
clients/web/src/hooks/useMcpApps.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import type { ElicitResult, Resource } from "@modelcontextprotocol/client";
|
||||||
|
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||||
|
import type {
|
||||||
|
AppRendererHandle,
|
||||||
|
BridgeFactory,
|
||||||
|
} from "../components/elements/AppRenderer/AppRenderer";
|
||||||
|
import { createAppBridgeFactory } from "../components/elements/AppRenderer/createAppBridgeFactory";
|
||||||
|
import { publishAppDocument } from "../lib/publishAppDocument";
|
||||||
|
import {
|
||||||
|
AppElicitationController,
|
||||||
|
type AppElicitationEntry,
|
||||||
|
type AppElicitationSession,
|
||||||
|
} from "../lib/appElicitationController";
|
||||||
|
import { getAuthToken } from "../lib/authToken";
|
||||||
|
|
||||||
|
export interface UseMcpAppsOptions {
|
||||||
|
/** The live client. Null while disconnected; the bridges read it lazily. */
|
||||||
|
inspectorClient: InspectorClient | null;
|
||||||
|
/** Origin the backend's `/api/*` routes are reached on. */
|
||||||
|
configBaseUrl: string;
|
||||||
|
/**
|
||||||
|
* Whatever the resource list currently holds — every page in the default
|
||||||
|
* aggregate mode, only the pages fetched so far under `paginatedLists`. Read
|
||||||
|
* through a ref rather than a dependency (see below), so a new array
|
||||||
|
* identity on every render costs nothing here.
|
||||||
|
*/
|
||||||
|
resources: Resource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpApps {
|
||||||
|
/** Pushes tool input/result into the running app, and tears it down. */
|
||||||
|
appRendererRef: RefObject<AppRendererHandle | null>;
|
||||||
|
/** Bridge for an App-tool frame in the Apps tab. */
|
||||||
|
sandboxBridgeFactory: BridgeFactory;
|
||||||
|
/** Bridge for an app-rendered elicitation modal (advertises `elicitation`). */
|
||||||
|
elicitationBridgeFactory: BridgeFactory;
|
||||||
|
/** The elicitations currently awaiting an answer, for `AppElicitationHost`. */
|
||||||
|
appElicitations: AppElicitationEntry[];
|
||||||
|
/**
|
||||||
|
* Close the previous client's session and open one for the client being
|
||||||
|
* constructed. Synchronous, and called at construction, so the swap itself is
|
||||||
|
* the moment ownership changes hands.
|
||||||
|
*/
|
||||||
|
newAppElicitationSession: () => AppElicitationSession;
|
||||||
|
handleAppElicitationSettle: (requestId: string, result: ElicitResult) => void;
|
||||||
|
handleAppElicitationFail: (requestId: string, error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The MCP Apps runtime wiring — the two sandbox bridge factories, the renderer
|
||||||
|
* handle, and the app-rendered elicitation controller (#1854) — lifted out of
|
||||||
|
* `App.tsx` by the phase-2 decomposition (#2156, under #2129/#2126).
|
||||||
|
*
|
||||||
|
* Nearly a leaf: it reads the active client and the resource listing and calls
|
||||||
|
* nothing back. What keeps it one hook rather than several is that both
|
||||||
|
* factories are built from the same two lazily-read inputs, and the elicitation
|
||||||
|
* session is owned by the controller the second factory renders through.
|
||||||
|
*
|
||||||
|
* The caller still owns `sandboxUrl` and the client construction that consumes
|
||||||
|
* `newAppElicitationSession` — whether the sandbox exists decides whether the
|
||||||
|
* nested MCP Apps `elicitation` capability is advertised, and that is a
|
||||||
|
* connection-lifecycle question rather than an Apps one.
|
||||||
|
*/
|
||||||
|
export function useMcpApps({
|
||||||
|
inspectorClient,
|
||||||
|
configBaseUrl,
|
||||||
|
resources,
|
||||||
|
}: UseMcpAppsOptions): McpApps {
|
||||||
|
// `sandboxUrl` is the inspector's sandbox-proxy page (the trusted outer
|
||||||
|
// iframe); `appRendererRef` lets the app handlers push tool input/result into
|
||||||
|
// the running app and tear it down. The bridge factory wraps the active
|
||||||
|
// client's underlying SDK client so the running view can call the server, and
|
||||||
|
// reads the tool's UI resource into the sandbox on handshake.
|
||||||
|
const appRendererRef = useRef<AppRendererHandle>(null);
|
||||||
|
|
||||||
|
// The `resources/list` entries, read lazily by the App bridge factories below.
|
||||||
|
// ext-apps treats a listing entry's `_meta.ui` as the static default for its
|
||||||
|
// UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP
|
||||||
|
// has to be able to see it. A ref rather than a dependency: the factories only
|
||||||
|
// read it inside an async sandboxready handler, long after any render that
|
||||||
|
// produced the listing.
|
||||||
|
//
|
||||||
|
// The listing is a documented *default* that a read content item overrides,
|
||||||
|
// and an app whose entry sits on an unfetched page simply falls back to no
|
||||||
|
// hints (`connect-src 'none'`), exactly as before this wiring existed.
|
||||||
|
// Walking the whole list to close that would issue the very requests the user
|
||||||
|
// opted out of by turning pagination on, so the setting wins.
|
||||||
|
const listedResourcesRef = useRef<Resource[]>([]);
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||||
|
listedResourcesRef.current = resources;
|
||||||
|
// Best-effort by construction: this reads the list as it stands when the app
|
||||||
|
// opens, and does not distinguish "no entry for this URI" from "the list
|
||||||
|
// hasn't arrived yet". Both yield no hints, which is the same outcome as
|
||||||
|
// having no listing carrier at all. Blocking the render on list readiness
|
||||||
|
// instead would mean waiting on a request that, for a server advertising no
|
||||||
|
// `resources` capability or whose list errored, never resolves — trading a
|
||||||
|
// missing default for an app that never renders.
|
||||||
|
const getListedResourceMeta = useCallback(
|
||||||
|
(uri: string) =>
|
||||||
|
listedResourcesRef.current.find((r) => r.uri === uri)?._meta,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// `_meta.ui.domain` support (#2056): hand a wrapped app document to the
|
||||||
|
// backend so it can serve it from a dedicated origin, giving the app's
|
||||||
|
// requests a real `Origin`. Resolves `null` on any backend that can't, and
|
||||||
|
// the factory then renders the app the default (opaque-origin) way.
|
||||||
|
const publishDocument = useCallback(
|
||||||
|
(doc: { html: string; csp?: string }) =>
|
||||||
|
publishAppDocument(doc, {
|
||||||
|
baseUrl: configBaseUrl,
|
||||||
|
authToken: getAuthToken(),
|
||||||
|
}),
|
||||||
|
[configBaseUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sandboxBridgeFactory = useMemo(
|
||||||
|
() =>
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||||
|
createAppBridgeFactory({
|
||||||
|
publishAppDocument: publishDocument,
|
||||||
|
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||||
|
getListedResourceMeta,
|
||||||
|
readResource: async (uri) => {
|
||||||
|
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||||
|
const invocation = await inspectorClient.readResource(uri);
|
||||||
|
return invocation.result;
|
||||||
|
},
|
||||||
|
// The bridge's sandboxready handler reads + posts the UI resource
|
||||||
|
// inside a detached async block; without this hook a 404 / malformed
|
||||||
|
// resource is console.error-only and the user stares at a blank
|
||||||
|
// frame. Surface it as a toast. The renderer separately drives
|
||||||
|
// `data-app-status` so an automated driver can time out on
|
||||||
|
// never-reaching-"ready" and read the toast.
|
||||||
|
onResourceError: (err) => {
|
||||||
|
notifications.show({
|
||||||
|
title: "App resource failed to load",
|
||||||
|
message: err.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
// App-rendered form elicitations (#1854). The controller is created once and
|
||||||
|
// handed to every InspectorClient at construction — its `render` is what opts
|
||||||
|
// this client into advertising the nested MCP Apps `elicitation` capability,
|
||||||
|
// which is why only the web client (the one with a sandbox) claims it.
|
||||||
|
const appElicitationControllerRef = useRef<AppElicitationController>(null);
|
||||||
|
appElicitationControllerRef.current ??= new AppElicitationController();
|
||||||
|
const appElicitationController = appElicitationControllerRef.current;
|
||||||
|
// The window onto the controller for the CURRENT client. Closing it when the
|
||||||
|
// client is replaced both rejects that connection's queued requests and
|
||||||
|
// refuses any it enqueues during its own (asynchronous) teardown — a late
|
||||||
|
// entry would otherwise be rendered by a factory bound to the replacement
|
||||||
|
// client, i.e. read and answered through a different server.
|
||||||
|
const appElicitationSessionRef = useRef<AppElicitationSession>(null);
|
||||||
|
const appElicitations = useSyncExternalStore(
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||||
|
appElicitationController.subscribe,
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||||
|
appElicitationController.getEntries,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A SECOND factory, differing from `sandboxBridgeFactory` only in that it
|
||||||
|
// advertises `hostCapabilities.elicitation`. An App-tool frame is never handed
|
||||||
|
// an elicitation, so telling those apps otherwise would be a false claim.
|
||||||
|
const elicitationBridgeFactory = useMemo(
|
||||||
|
() =>
|
||||||
|
// eslint-disable-next-line react-hooks/refs -- pre-existing latest-ref pattern, unmasked when this component dropped below the React Compiler's bail-out (#2161)
|
||||||
|
createAppBridgeFactory({
|
||||||
|
advertiseElicitation: true,
|
||||||
|
publishAppDocument: publishDocument,
|
||||||
|
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||||
|
getListedResourceMeta,
|
||||||
|
readResource: async (uri) => {
|
||||||
|
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||||
|
const invocation = await inspectorClient.readResource(uri);
|
||||||
|
return invocation.result;
|
||||||
|
},
|
||||||
|
// Unlike the Apps tab there is no persistent surface to show the
|
||||||
|
// failure on — the modal is about to be replaced by the native form —
|
||||||
|
// so the toast is the only place the user learns why.
|
||||||
|
onResourceError: (err) => {
|
||||||
|
notifications.show({
|
||||||
|
title: "Elicitation app failed to load",
|
||||||
|
message: err.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||||
|
);
|
||||||
|
|
||||||
|
const newAppElicitationSession = useCallback(() => {
|
||||||
|
appElicitationSessionRef.current?.close(
|
||||||
|
new Error("Connection replaced before the app answered"),
|
||||||
|
);
|
||||||
|
const session = appElicitationController.openSession();
|
||||||
|
appElicitationSessionRef.current = session;
|
||||||
|
return session;
|
||||||
|
}, [appElicitationController]);
|
||||||
|
|
||||||
|
const handleAppElicitationSettle = useCallback(
|
||||||
|
(requestId: string, result: ElicitResult) => {
|
||||||
|
appElicitationController.settle(requestId, result);
|
||||||
|
},
|
||||||
|
[appElicitationController],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAppElicitationFail = useCallback(
|
||||||
|
(requestId: string, error: Error) => {
|
||||||
|
appElicitationController.fail(requestId, error);
|
||||||
|
},
|
||||||
|
[appElicitationController],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
appRendererRef,
|
||||||
|
sandboxBridgeFactory,
|
||||||
|
elicitationBridgeFactory,
|
||||||
|
appElicitations,
|
||||||
|
newAppElicitationSession,
|
||||||
|
handleAppElicitationSettle,
|
||||||
|
handleAppElicitationFail,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue