chore(web): extract App.tsx's MCP Apps wiring into useMcpApps
Step 5 of the phase-2 App.tsx decomposition (#2129, under #2126): the two sandbox bridge factories, the renderer handle, and the app-rendered elicitation controller move out of App.tsx into a hook of their own. Nearly a leaf — it reads the active client and the resource listing and calls nothing back — so `getListedResourceMeta`, `publishDocument` and `listedResourcesRef` become internal to the hook rather than App.tsx locals. The hook is called where `resources` is derived, which is what lets it own the latest-listing ref write too. `sandboxUrl` and `initialConfigSettledRef` deliberately stay in App.tsx: whether the sandbox exists decides whether the client advertises the nested MCP Apps `elicitation` capability, and that is a connection-lifecycle question rather than an Apps one. Inert by construction: no behavior change, and the extraction is covered 100% on all four dimensions plus the existing `smoke:web:app` and `smoke:web:elicit`, in both Chromium and Firefox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
70fffa8d86
commit
02a56e8e89
1 changed files with 646 additions and 160 deletions
|
|
@ -5,7 +5,6 @@ import {
|
|||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
|
@ -14,7 +13,6 @@ import type {
|
|||
ElicitResult,
|
||||
InitializeResult,
|
||||
LoggingLevel,
|
||||
Resource,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
|
|
@ -77,6 +75,7 @@ import {
|
|||
type SetupClientForServer,
|
||||
} from "./hooks/useOAuthRecovery";
|
||||
import { useInspectorStores } from "./hooks/useInspectorStores";
|
||||
import { useMcpApps } from "./hooks/useMcpApps";
|
||||
import { useExportActions } from "./hooks/useExportActions";
|
||||
import { useProgressToasts } from "./hooks/useProgressToasts";
|
||||
import { useTaskToasts } from "./hooks/useTaskToasts";
|
||||
|
|
@ -92,14 +91,7 @@ import type {
|
|||
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
|
||||
import type { ReadResourceState } from "./components/screens/ResourcesScreen/ResourcesScreen";
|
||||
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 {
|
||||
AppElicitationController,
|
||||
type AppElicitationSession,
|
||||
} from "./lib/appElicitationController";
|
||||
import type { LogEntryData } from "./components/elements/LogEntry/LogEntry";
|
||||
import {
|
||||
ServerConfigModal,
|
||||
|
|
@ -251,12 +243,6 @@ function App() {
|
|||
const [inspectorClient, setInspectorClient] =
|
||||
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 =
|
||||
typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
||||
// One `GET /api/config` fetch recovers every static payload field the app
|
||||
|
|
@ -296,81 +282,6 @@ function App() {
|
|||
});
|
||||
}, [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
|
||||
// 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
|
||||
|
|
@ -398,66 +309,6 @@ function App() {
|
|||
useEffect(() => {
|
||||
if (!initialConfigLoading) initialConfigSettledRef.current?.resolve();
|
||||
}, [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
|
||||
// the parent keeps the current value locally.
|
||||
|
|
@ -695,15 +546,20 @@ function App() {
|
|||
const tools = toolsPagination.items;
|
||||
const prompts = promptsPagination.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
|
||||
// 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.
|
||||
// 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;
|
||||
|
||||
// MCP Apps runtime wiring (#2156): the sandbox bridge factories, the renderer
|
||||
// handle, and the app-rendered elicitation controller. Called here rather
|
||||
// than beside the other client-scoped hooks above because it reads the
|
||||
// resource listing, which is only derived at this point.
|
||||
const {
|
||||
appRendererRef,
|
||||
sandboxBridgeFactory,
|
||||
elicitationBridgeFactory,
|
||||
appElicitations,
|
||||
newAppElicitationSession,
|
||||
handleAppElicitationSettle,
|
||||
handleAppElicitationFail,
|
||||
} = useMcpApps({ inspectorClient, configBaseUrl, resources });
|
||||
|
||||
// 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
|
||||
|
|
@ -1882,12 +1738,13 @@ function App() {
|
|||
onAppError,
|
||||
activeServerId,
|
||||
handleCommandScopedAuthRecovery,
|
||||
appRendererRef,
|
||||
],
|
||||
);
|
||||
|
||||
const onCloseApp = useCallback(() => {
|
||||
void appRendererRef.current?.teardown();
|
||||
}, []);
|
||||
}, [appRendererRef]);
|
||||
|
||||
const onGetPrompt = useCallback(
|
||||
async (name: string, args: Record<string, string>) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue