Merge branch 'v2/main' into v2/feat/2151-ace-json-editor

This commit is contained in:
Cliff Hall 2026-08-27 20:07:11 -04:00 • committed by GitHub
commit ccd99083ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 2435 additions and 778 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,992 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { RefObject } from "react";
import { notifications } from "@mantine/notifications";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type {
ConnectionStatus,
InspectorServerSettings,
MCPServerConfig,
ServerEntry,
} from "@inspector/core/mcp/types.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
eraToVersionNegotiation,
} from "@inspector/core/mcp/types.js";
import {
applyStdioSettingsToConfig,
cleanRoots,
oauthAuthorizationParamsFromSettings,
oauthEndpointOverridesFromSettings,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import {
getActiveCimdClientMetadataUrl,
getActiveEnterpriseManagedAuthIdp,
} from "@inspector/core/client/types.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
import type { SessionRef } from "./useSessionRef";
import type { FetchLogOptions } from "./useInspectorStores";
import type { LastPersistedSettings } from "./useLastPersistedSettings";
import type { AppElicitationSession } from "../lib/appElicitationController";
import type {
PrepareOAuthRedirectArgs,
ReAuthBannerState,
SetupClientForServer,
} from "./useOAuthRecovery";
import { clearScrollMemory } from "./useScrollMemory";
import { createWebEnvironment } from "../lib/environmentFactory";
import { clearOAuthResumeSnapshot } from "../lib/oauthResume";
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
import type { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
import { getAuthToken, redirectUrlProvider } from "../lib/authToken";
import { OAUTH_CALLBACK_PATH, isUnauthorizedError } from "../utils/oauthFlow";
import { authRecoveryRestoredMessage } from "../utils/oauthUx";
import { deepLinkConfigEquals } from "../utils/deepLink";
import type { DeepLink } from "../utils/deepLink";
/**
* Handshake telemetry: the "connecting" edge stamps `connectStartRef` and the
* "connected" edge consumes it into `latencyMs`.
*
* Split out of `useConnectionLifecycle` below only because of call order.
* `useOAuthRecovery` stamps the same ref — its `/oauth/callback` reconnect is a
* connect attempt like any other — and runs *before* the lifecycle hook, so the
* ref has to exist earlier than the hook that otherwise owns it. Keeping the
* pair here rather than in `App.tsx` keeps both halves in the file that owns
* connecting.
*/
export function useHandshakeTelemetry(connectionStatus: ConnectionStatus): {
connectStartRef: RefObject<number | undefined>;
latencyMs: number | undefined;
} {
const connectStartRef = useRef<number | undefined>(undefined);
const [latencyMs, setLatencyMs] = useState<number | undefined>(undefined);
// Capture observed handshake latency at the connecting → connected edge.
// Reset when the status leaves "connected" so the next connect starts
// clean (otherwise a stale latency would render on the next session).
useEffect(() => {
if (
connectionStatus === "connected" &&
connectStartRef.current !== undefined
) {
setLatencyMs(Date.now() - connectStartRef.current);
connectStartRef.current = undefined;
} else if (connectionStatus !== "connected") {
setLatencyMs(undefined);
}
}, [connectionStatus]);
return { connectStartRef, latencyMs };
}
/**
* The narrow reset surface `useConnectionLifecycle` takes from each hook that
* owns a slice of session-scoped UI state, rather than the individual setters.
*
* Taking the setters one at a time would make this hook depend on nearly
* everything phase 1 extracted, which would decouple nothing (#2129).
*/
export interface SessionResetSurface {
/** Drops the in-flight tool / prompt / resource result panels. */
clearResultPanels: () => void;
/** Clears per-screen selection, search and filter state. */
resetTabUiState: () => void;
/** Clears the taskId → progress map. */
resetTaskProgress: () => void;
/** Drops the re-auth banner and both pending-OAuth slots. */
resetOAuthRecoveryState: () => void;
/** Re-seeds both log-level controls for the outgoing session. */
resetLogLevels: () => void;
/** Closes the Connection Info modal so it can't pop back on reconnect. */
closeConnectionInfoModal: () => void;
}
export interface UseConnectionLifecycleOptions {
/** The stable session mirror from `useSessionRef` (#2129). */
sessionRef: SessionRef;
servers: ServerEntry[];
activeServerId: string | undefined;
inspectorClient: InspectorClient | null;
connectionStatus: ConnectionStatus;
/** Catalog writes the deep-link auto-connect performs before connecting. */
addServer: (id: string, config: MCPServerConfig) => Promise<unknown>;
updateServer: (
id: string,
nextId: string,
config: MCPServerConfig,
) => Promise<unknown>;
setActiveServerId: (id: string | undefined) => void;
/** Red-borders a server in the list (#1621). */
setFailedServerId: (id: string | undefined) => void;
/** Publishes the client this hook constructs back to `App.tsx`. */
setInspectorClient: (client: InspectorClient | null) => void;
/** Per-session state managers, torn down and rebuilt around each connect. */
createStores: (
client: InspectorClient,
fetchLogOptions: FetchLogOptions,
) => void;
destroyStores: () => void;
/** What each settings write actually put on disk, per server (#2089). */
lastPersistedSettings: Pick<LastPersistedSettings, "resolve">;
/** Install-level client config — the EMA idp and CIMD URL come from it. */
clientConfig: ClientConfig;
/**
* Opens an app-rendered elicitation session for a newly built client, and
* returns the renderer the client advertises the capability with (#1854).
*/
newAppElicitationSession: () => AppElicitationSession;
/**
* The MCP Apps sandbox URL, or `undefined` once `/api/config` has settled
* and reported none. Whether the client may advertise the nested
* `elicitation` capability is decided at construction from this (#1854).
*/
sandboxUrl: string | undefined;
/**
* Resolves once `/api/config` has settled, so a connect waits for the
* sandbox answer rather than guessing it.
*/
initialConfigSettledRef: RefObject<{ promise: Promise<void> } | null>;
/** Handshake telemetry from `useHandshakeTelemetry` above. */
connectStartRef: RefObject<number | undefined>;
/**
* Where the `/oauth/callback` rebuild reads the connect path from. Published
* here in a layout effect; see `useOAuthRecovery` for why it is a ref.
*/
setupClientForServerRef: RefObject<SetupClientForServer | null>;
/** The validated deep link, or `undefined` when the URL carries none. */
deepLink: DeepLink | undefined;
// --- The OAuth recovery surface this hook consumes (#2153). ---
webOAuthStorage: ReturnType<typeof getWebRemoteOAuthStorage>;
sessionStorageAdapter: RemoteInspectorClientStorage;
onBeforeOAuthRedirect: (authorizationUrl: URL) => void;
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
finalizeExplicitDisconnect: () => void;
reAuthBanner: ReAuthBannerState | null;
setReAuthBanner: (next: ReAuthBannerState | null) => void;
/** See `SessionResetSurface`. */
sessionReset: SessionResetSurface;
/** Seeds the modern per-request log level from a server's settings. */
seedModernLogLevel: (settings: InspectorServerSettings | undefined) => void;
}
export interface ConnectionLifecycle {
/** The server that just connected — drives its card's green highlight. */
connectedServerId: string | undefined;
/** Last connection-level failure, surfaced as `data-error-message`. */
connectErrorMessage: string | undefined;
/** Connect to `id`, or disconnect when it is already the live session. */
onToggleConnection: (id: string) => Promise<void>;
/** Header Disconnect: end the live session explicitly. */
onDisconnect: () => Promise<void>;
/** Re-auth banner action — retry, or clear stale state and reconnect. */
onReauthenticateFromBanner: () => void;
}
/**
* The whole connection lifecycle: constructing the `InspectorClient` for a
* server, connecting and disconnecting it, the effects that observe those
* transitions, and the session-scoped reset a disconnect triggers. Lifted out
* of `App.tsx` by phase-2 step 3 of the decomposition (#2154, under
* #2129/#2126).
*
* It is one hook because the pieces share the same edge. `setupClientForServer`
* is the single place a connection is configured, and every entry point — the
* user's toggle, the header's Disconnect, the deep-link auto-connect, the
* re-auth banner, and (through `setupClientForServerRef`) the
* `/oauth/callback` rebuild — has to reach the *same* one, or two of them
* would build differently-configured clients for one server.
*
* Direction is one-way, as it was for `useOAuthRecovery`: this hook consumes
* that one's redirect plumbing (`onBeforeOAuthRedirect`,
* `sessionStorageAdapter`, `prepareOAuthRedirect`) and never the reverse. The
* one thing OAuth recovery needs from here — rebuilding a client — is injected
* back through `setupClientForServerRef`, published in a *layout* effect so it
* is set before any passive effect of the same commit can read it.
*/
export function useConnectionLifecycle({
sessionRef,
servers,
activeServerId,
inspectorClient,
connectionStatus,
addServer,
updateServer,
setActiveServerId,
setFailedServerId,
setInspectorClient,
createStores,
destroyStores,
lastPersistedSettings,
clientConfig,
newAppElicitationSession,
sandboxUrl,
initialConfigSettledRef,
connectStartRef,
setupClientForServerRef,
deepLink,
webOAuthStorage,
sessionStorageAdapter,
onBeforeOAuthRedirect,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
reAuthBanner,
setReAuthBanner,
sessionReset,
seedModernLogLevel,
}: UseConnectionLifecycleOptions): ConnectionLifecycle {
const [connectedServerId, setConnectedServerId] = useState<
string | undefined
>(undefined);
// `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
// ref is written every render, so client construction reads the current value
// whichever entry point (connect, deep link, OAuth callback) reached it.
const sandboxUrlRef = useRef<string | undefined>(undefined);
// 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)
sandboxUrlRef.current = sandboxUrl;
const {
clearResultPanels,
resetTabUiState,
resetTaskProgress,
resetOAuthRecoveryState,
resetLogLevels,
closeConnectionInfoModal,
} = sessionReset;
const deepLinkEnsureRef = useRef(false);
const deepLinkUpdateRef = useRef(false);
const deepLinkConnectRef = useRef(false);
// Track the just-connected server so its card gets the green highlight +
// scroll-into-view (#1682). Unlike `failedServerId` (which must survive the
// `disconnect` event a failed connect fires), "connected" is a stable status,
// so a status-driven effect can both set and clear it: set on connect, clear
// whenever the session isn't connected (disconnect, a new attempt's
// "connecting", or an error).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
setConnectedServerId(
connectionStatus === "connected" ? activeServerId : undefined,
);
}, [connectionStatus, activeServerId]);
// Disconnect the previous InspectorClient when it's replaced (server
// switch) or when App unmounts (HMR, tests). Without this the prior
// session's transport — a spawned stdio subprocess, an SSE stream, or
// an HTTP session — stays open until GC eventually lets go. The
// state-manager destroys in `setupClientForServer` only handle the
// listener side; this effect handles the transport side. `disconnect()`
// is the canonical lifecycle hook (InspectorClient has no `destroy()`);
// it closes the transport, clears subscriptions, cancels receiver TTLs.
useEffect(() => {
return () => {
if (inspectorClient) {
void inspectorClient.disconnect();
}
};
}, [inspectorClient]);
// Reset the session-scoped UI state that lives outside the per-server state
// managers, so the next server's screens don't show server A's last result.
// The per-call panels (`toolCallState` / `getPromptState` /
// `readResourceState`) and the optimistic log levels all survive a
// disconnect/reconnect cycle otherwise — see #1368. `latencyMs` is
// intentionally excluded: it resets via the `connectionStatus` effect in
// `useHandshakeTelemetry`, which has its own connecting-edge ref to
// coordinate with. Each piece is dropped through its owner's `reset()`
// rather than through that owner's setters, so this stays one call per
// owner as more session-scoped state accrues (#1394, #2129).
// Does not clear the OAuth resume snapshot — that is tied to an in-flight
// full-page redirect and is cleared on explicit disconnect or consumed on callback.
const resetSessionScopedUiState = useCallback(() => {
clearResultPanels();
resetTabUiState();
resetTaskProgress();
resetLogLevels();
resetOAuthRecoveryState();
// Remembered scroll offsets are session-scoped too — drop them so the next
// session's screens start at the top (#1417).
clearScrollMemory();
}, [
clearResultPanels,
resetTabUiState,
resetTaskProgress,
resetLogLevels,
resetOAuthRecoveryState,
]);
// Reset activeServerId whenever the live session ends. Without this the
// other ServerCards stay `inert` after disconnect — ServerCard dims any
// card whose id differs from `activeServer`. Subscribing to
// InspectorClient's own `disconnect` event covers all three paths
// (explicit toggle, header Disconnect button, mid-session transport
// failure / process exit) and avoids the first-render-clobbers-new-id
// trap that watching connectionStatus has (status starts as
// "disconnected" for the new client before connect() runs). The
// session-scoped panel/level reset rides along here too via
// `resetSessionScopedUiState`.
useEffect(() => {
if (!inspectorClient) return;
const onDisconnect = () => {
setActiveServerId(undefined);
// Drop the open flag too — without this the modal would pop back the
// next time `initializeResult` re-becomes truthy (e.g. reconnect).
closeConnectionInfoModal();
resetSessionScopedUiState();
};
inspectorClient.addEventListener("disconnect", onDisconnect);
return () => {
inspectorClient.removeEventListener("disconnect", onDisconnect);
};
}, [
inspectorClient,
setActiveServerId,
closeConnectionInfoModal,
resetSessionScopedUiState,
]);
// Last connection-level error message, surfaced as `data-error-message` on
// the InspectorView header so an automated driver can read *why* a connect
// failed without scraping a transient toast. Cleared on the next connect
// attempt and on successful connection.
const [connectErrorMessage, setConnectErrorMessage] = useState<
string | undefined
>(undefined);
// Named writer so a single call site can be extended later (e.g. telemetry)
// and the intent (record a connection-level failure) stays explicit.
const recordConnectError = useCallback((message: string) => {
setConnectErrorMessage(message);
}, []);
// Wire up + tear down per active server. Called by `onToggleConnection`
// when the user switches targets. Returns the new client so the toggle
// can call `connect()` against it before React re-renders.
const setupClientForServer = useCallback(
(server: ServerEntry, sessionId?: string): InspectorClient => {
// Tear down the previous session's managers before building the new
// client — each destroy() unsubscribes from the old client's events, and
// doing it here (rather than leaving it to `createStores` below, which
// also tears down whatever is live) means a throw while constructing the
// client leaves nothing still listening to the outgoing one. A no-op on
// the first call.
destroyStores();
const { environment, logger } = createWebEnvironment(
getAuthToken(),
redirectUrlProvider,
onBeforeOAuthRedirect,
);
// The settings node persisted in mcp.json for this server — distinct
// from the InspectorClient options we're about to derive from it.
//
// Through the tracker, not `server.settings` directly: the entry only
// advances on a successful list read, so a write that landed while reads
// were failing — including one made from the settings modal for a server
// that was not connected at the time — would otherwise be undone at the
// next connect, which builds the client from that frozen entry. This is
// the one place the whole connection is configured, so every construction
// path (connect, reconnect, OAuth resume) goes through it (#2089).
const savedSettings =
lastPersistedSettings.resolve(server.id) ?? server.settings;
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
// Flatten the persisted settings into the InspectorClient options shape.
// Empty / zero values stay unset so the SDK defaults apply.
// Per-server default `_meta` is already a JSON object (#1910) — no
// pair-array flattening left to do; `{}` means "no defaults".
const defaultMetadata = savedSettings?.metadata;
const serverAuthorizationParams = savedSettings
? oauthAuthorizationParamsFromSettings(savedSettings)
: undefined;
const serverEndpointOverrides = savedSettings
? oauthEndpointOverridesFromSettings(savedSettings)
: undefined;
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes ||
serverAuthorizationParams ||
serverEndpointOverrides ||
savedSettings.enterpriseManaged ||
savedSettings.oauthRequestRefreshToken === false)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
}),
...(savedSettings.oauthClientSecret && {
clientSecret: savedSettings.oauthClientSecret,
}),
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
...(serverAuthorizationParams && {
authorizationParams: serverAuthorizationParams,
}),
...serverEndpointOverrides,
...(savedSettings.enterpriseManaged && {
enterpriseManaged: true,
}),
// #2068: only the explicit opt-out is forwarded; omitting the key
// leaves the provider's default (declare `refresh_token`) in place.
...(savedSettings.oauthRequestRefreshToken === false && {
requestRefreshToken: false,
}),
}
: undefined;
const oauth =
oauthFromServer || activeCimdUrl
? {
...(oauthFromServer ?? {}),
...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }),
}
: undefined;
// The stdio `env` / `cwd` are edited as *settings* but stored on — and
// read by the transport from — *config*, so the tracker's account of the
// former has to be carried onto the latter. `server.config` comes off the
// same frozen `servers` entry `server.settings` does, so without this a
// save that landed while list reads were failing spawns the child process
// with the pre-save environment while the modal, re-seeded from the
// tracker, shows the new one (#2096). Same mapping the PUT route applies
// when persisting it, from the same helper.
const effectiveConfig = applyStdioSettingsToConfig(
server.config,
savedSettings,
);
const client = new InspectorClient(effectiveConfig, {
environment,
// The Tasks tab needs the receiver-task pipeline; the
// requestor-task list comes from the client's task store.
receiverTasks: true,
// Sampling / elicitation are on by default; keep the parameterized
// options off until the UI grows the surface to render them.
elicit: { form: true, url: true },
// Web only, and only when the sandbox renderer is actually available:
// supplying this advertises the nested MCP Apps `elicitation`
// capability, and a client that cannot host an app must not claim it
// (#1854). Callers await `initialConfigSettled` first, so `sandboxUrl`
// here means "confirmed absent" rather than "not known yet" — a
// connection that reaches this with no sandbox behaves like the
// CLI/TUI: native elicitation queue, no claim made to the server.
...(sandboxUrlRef.current && {
appElicitation: newAppElicitationSession().render,
}),
// Always advertise the roots capability (even with no configured
// roots) so the server can issue roots/list and receive
// roots/list_changed; the configured roots are the answer to
// roots/list. Empty-uri rows are dropped before they reach the wire.
roots: cleanRoots(savedSettings?.roots ?? []),
...(savedSettings &&
savedSettings.requestTimeout > 0 && {
timeout: savedSettings.requestTimeout,
}),
...(defaultMetadata &&
Object.keys(defaultMetadata).length > 0 && {
defaultMetadata,
}),
...(oauth && { oauth }),
...(activeIdp && {
enterpriseManagedAuth: { idp: activeIdp },
}),
...(clientConfig.enterpriseManagedAuth && {
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
}),
...(savedSettings && { serverSettings: savedSettings }),
// Per-server protocol era (SEP §7.8) → SDK versionNegotiation. Absent
// settings or an unset era default to legacy inside
// eraToVersionNegotiation / the InspectorClient constructor (#1626).
...(savedSettings?.protocolEra && {
versionNegotiation: eraToVersionNegotiation(
savedSettings.protocolEra,
),
}),
// Per-server advertised-extension overrides (#1739). Absent/empty falls
// back to the registry defaults in the InspectorClient constructor.
...(savedSettings?.advertisedExtensions &&
Object.keys(savedSettings.advertisedExtensions).length > 0 && {
advertisedExtensions: savedSettings.advertisedExtensions,
}),
// Set on the `/oauth/callback` rebuild so the client's `saveSession`
// events (and any later persistence) key off the same OAuth authId
// the pre-redirect page saved under.
...(sessionId && { sessionId }),
});
setInspectorClient(client);
// #1629: seed the live modern per-request log level from the server
// setting so the Logs-tab control reflects what the client stamps by
// default (the client was seeded the same way in its constructor). "off"
// means not opted in (null). Only affects modern connections.
seedModernLogLevel(savedSettings);
// Wire session storage so the fetch log survives the OAuth redirect.
// When `sessionId` is supplied (the `/oauth/callback` rebuild) the prior
// page's `auth` entries are restored on construction; the actual save is
// driven synchronously from `onBeforeOAuthRedirect` above (keyed by the
// same authId). `createStores` points `fetchLogRef` at the new instance
// so that hook reads the current log.
createStores(client, {
sessionStorage: sessionStorageAdapter,
logger,
maxFetchRequests:
savedSettings?.maxFetchRequests ?? DEFAULT_MAX_FETCH_REQUESTS,
...(sessionId && { sessionId }),
});
return client;
},
[
createStores,
destroyStores,
setInspectorClient,
seedModernLogLevel,
sessionStorageAdapter,
onBeforeOAuthRedirect,
clientConfig,
newAppElicitationSession,
lastPersistedSettings,
],
);
// Publish it to the `/oauth/callback` effect, which needs to rebuild the
// client for the server that started the flow and cannot reach a callback
// declared this far down.
//
// A *layout* effect, not a render-phase write and not a passive one. React
// runs every layout effect before any passive effect of the same commit, and
// the callback effect inside `useOAuthRecovery` is passive — so this is
// always published before its first read, without the render-phase ref
// mutation the compiler rule (rightly) rejects.
useLayoutEffect(() => {
setupClientForServerRef.current = setupClientForServer;
}, [setupClientForServerRef, setupClientForServer]);
const onToggleConnection = useCallback(
async (id: string) => {
// Whether this client may advertise app-rendered elicitation is decided
// at construction and cannot be revised afterwards, so wait for the fact
// rather than guess it (see `initialConfigSettledRef`). Already resolved
// by the time any human clicks; this only orders a deep-link auto-connect
// that races the same page load.
await initialConfigSettledRef.current?.promise;
// Same server, already connected → disconnect.
if (
id === activeServerId &&
connectionStatus === "connected" &&
inspectorClient
) {
try {
await inspectorClient.disconnect();
} finally {
finalizeExplicitDisconnect();
}
return;
}
// Read from the ref so a caller that already awaited an
// addServer/updateServer in the same async tick (e.g. the deep-link
// auto-connect IIFE) sees the freshly-mutated list, not the stale array
// captured by this callback's closure.
const target = sessionRef.current.servers.find((s) => s.id === id);
if (!target) return;
// Always rebuild the InspectorClient on a (re)connect so the latest
// `target.settings` (headers, metadata, timeouts, OAuth credentials)
// are picked up. Reusing the previous client object would freeze the
// settings at the moment it was first constructed, which would be
// surprising right after the user edited them in the settings modal.
const client = setupClientForServer(target);
if (id !== activeServerId) {
setActiveServerId(id);
}
// A new connection attempt has begun: clear any previous failure flag so
// the red border on the last-failed card is removed (#1621). If this
// attempt also fails, the catch below re-sets it for this server.
setFailedServerId(undefined);
// Clear the machine-readable connect error for the same reason; a fresh
// attempt starts from a clean `data-error-message`.
setConnectErrorMessage(undefined);
connectStartRef.current = Date.now();
try {
// `settings.connectionTimeout` is consumed inside InspectorClient.connect
// (Promise.race + transport teardown live there now), so this branch
// stays unaware of the per-server timeout. TUI/CLI consumers get the
// same behavior by reading from `serverSettings` on the client.
await client.connect();
} catch (err) {
// Handshake-only. A mid-session transport failure does not throw; the
// client's `error` event surfaces those, consumed via
// `useInspectorClient`'s `lastError` and toasted in the effect above
// (#1323).
connectStartRef.current = undefined;
if (isEmaClientNotConfiguredError(err)) {
notifications.show({
title: `Cannot connect to "${target.name}"`,
message: err.message,
color: "red",
autoClose: false,
});
return;
}
// A 401 from an OAuth-protected server means we have no (valid) token
// yet. Kick off the authorization-code flow: `authenticate()` runs
// discovery + DCR (proxied through the backend), then redirects the
// whole page to the auth server via `BrowserNavigation`. Persist the
// initiating server id first so the `/oauth/callback` load can resume
// against the right client. The redirect unloads this page, so there's
// nothing to do after the await on the success path.
if (err instanceof AuthRecoveryRequiredError) {
try {
if (await client.checkAuthChallengeSatisfied(err.authChallenge)) {
connectStartRef.current = Date.now();
await client.connect();
return;
}
} catch (recoveryErr) {
// Both awaits above are unguarded connect work sitting inside a
// `catch`, so a rejection escapes `onToggleConnection` altogether:
// no toast, no red border, no sidebar — the #2108 failure mode in
// its most invisible form. Surface it as the failed connect attempt
// it is. A throw from `checkAuthChallengeSatisfied` lands here too
// rather than falling through to `prepareOAuthRedirect`: it is not
// the same as the challenge being *unsatisfied*, and navigating the
// whole page away on the strength of an error would bury it.
connectStartRef.current = undefined;
// Tear the session down before reporting, as the sibling OAuth
// catch below does. The outer `connect()` rejected with an
// auth-recovery error, which deliberately holds the status at
// `"connecting"` rather than moving it to `"error"` — so if the
// challenge check is what rejected, nothing else ever ends the
// attempt and the toggle spins while the active-server lock is
// held. The fetch log survives a disconnect, so the Network
// diagnostics this issue is about are unaffected.
await client.disconnect().catch(() => {});
setFailedServerId(id);
const message =
recoveryErr instanceof Error
? recoveryErr.message
: String(recoveryErr);
setConnectErrorMessage(message);
notifications.show({
title: `Failed to connect to "${target.name}"`,
message,
color: "red",
});
return;
}
prepareOAuthRedirect({
serverId: id,
authKind: "reauth",
authorizationUrl: err.authorizationUrl,
preRedirectContext: "connect",
client,
});
return;
}
if (isUnauthorizedError(err)) {
try {
const authUrl = await client.authenticate();
if (authUrl === undefined) {
connectStartRef.current = Date.now();
await client.connect();
} else {
prepareOAuthRedirect({
serverId: id,
authKind: "reauth",
authorizationUrl: authUrl,
preRedirectContext: "connect",
client,
});
}
return;
} catch (authErr) {
clearOAuthResumeSnapshot();
await client.disconnect().catch(() => {});
if (isEmaClientNotConfiguredError(authErr)) {
notifications.show({
title: `Cannot connect to "${target.name}"`,
message: authErr.message,
color: "red",
autoClose: false,
});
return;
}
// The connect attempt failed, same as any other handshake error —
// flag the card (#1621) and, with it, open the monitoring sidebar
// onto the OAuth requests that explain the failure (#2108). This
// leg never reaches the `"error"` connection status (the
// `disconnect()` above settles it at `"disconnected"`), so this
// flag is the only signal the view has that a connect attempt died.
setFailedServerId(id);
const message =
authErr instanceof Error ? authErr.message : String(authErr);
setConnectErrorMessage(message);
notifications.show({
title: `OAuth authorization failed for "${target.name}"`,
message,
color: "red",
});
return;
}
}
// Non-auth handshake error: toast so the user sees what went wrong
// instead of the ConnectionToggle silently reverting to
// "disconnected", and flag the card with a red border (#1621).
setFailedServerId(id);
const message = err instanceof Error ? err.message : String(err);
setConnectErrorMessage(message);
notifications.show({
title: `Failed to connect to "${target.name}"`,
message,
color: "red",
});
}
},
[
sessionRef,
activeServerId,
connectionStatus,
inspectorClient,
initialConfigSettledRef,
connectStartRef,
setupClientForServer,
setActiveServerId,
setFailedServerId,
prepareOAuthRedirect,
finalizeExplicitDisconnect,
],
);
const onDisconnect = useCallback(async () => {
if (!inspectorClient) return;
try {
await inspectorClient.disconnect();
} finally {
finalizeExplicitDisconnect();
}
}, [inspectorClient, finalizeExplicitDisconnect]);
// Deep-link auto-connect (the URL-driven case of #1183). `useServers`
// hydrates asynchronously (initial `servers` is `[]`), so this effect runs in
// discrete phases keyed on what `servers` currently reflects, one per render:
// 1. ensure — no row yet: one-shot `addServer`.
// 2. update — row present but its persisted config differs from the deep
// link (a stale transport/url from an earlier load under the stable
// `deep-link` id): `updateServer`, then return so the effect re-runs.
// 3. connect — row present AND its config already matches: connect.
// Splitting update and connect across renders (rather than awaiting both in
// one closure) is what makes the connect correct: `onToggleConnection` reads
// the target from the session ref, which an earlier passive effect syncs from
// `servers` — so connecting only once `servers` reflects the updated config
// guarantees the client is built from the fresh transport, not the stale one.
// The OAuth callback path takes precedence; a deep link on `/oauth/callback`
// would be a misconfiguration, and the callback handler clears the URL.
useEffect(() => {
if (!deepLink) return;
if (window.location.pathname === OAUTH_CALLBACK_PATH) return;
const existing = servers.find((s) => s.id === deepLink.serverId);
if (!existing) {
if (deepLinkEnsureRef.current) return;
deepLinkEnsureRef.current = true;
void addServer(deepLink.serverId, deepLink.serverConfig).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
// A 409 ("already exists") means the row is on disk and hydration will
// surface it on a later render, so the connect phase still proceeds —
// swallow it. Any other failure (read-only catalog, backend 5xx) would
// otherwise leave the deep link permanently stuck at this guard with no
// signal, so record it on the machine-readable error surface.
if (!message.includes("already exists")) recordConnectError(message);
});
return;
}
if (!deepLinkConfigEquals(existing.config, deepLink.serverConfig)) {
if (deepLinkUpdateRef.current) return;
deepLinkUpdateRef.current = true;
void updateServer(
deepLink.serverId,
deepLink.serverId,
deepLink.serverConfig,
).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
return;
}
if (deepLinkConnectRef.current) return;
deepLinkConnectRef.current = true;
// Connect unless we're already *connected* to the deep-link server. Gating
// on `activeServerId` identity alone would skip the connect when a prior
// session restored `activeServerId` to the `deep-link` id while the socket
// is disconnected — a reload of the same deep-link URL would then silently
// never connect. `onToggleConnection` only disconnects when the id is the
// active one AND the status is connected, so this condition also avoids
// toggling a live connection off.
const alreadyConnected =
activeServerId === deepLink.serverId && connectionStatus === "connected";
if (!alreadyConnected) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
void onToggleConnection(deepLink.serverId).catch((err) => {
// The toast fires from inside `onToggleConnection` for the common
// cases; this catch covers the rest (surfaced on `data-error-message`).
const message = err instanceof Error ? err.message : String(err);
recordConnectError(message);
});
}
}, [
deepLink,
servers,
activeServerId,
connectionStatus,
addServer,
updateServer,
onToggleConnection,
recordConnectError,
]);
/**
* The banner's connect, terminated. `onToggleConnection` handles every
* failure the *handshake* can produce, but client construction runs ahead of
* its try/catch — so a throw there (a wedged store teardown, a config the
* `InspectorClient` constructor rejects) escapes it. Both banner call sites
* discard the promise, which would make that an unhandled rejection with the
* banner already dismissed and nothing left to explain it.
*
* Recorded on `connectErrorMessage` rather than toasted, matching the
* deep-link phases' own catch: the toggle already toasts everything it
* handles, so a toast here would be new user-visible behavior in what is
* otherwise an inert move.
*/
const retryConnect = useCallback(
async (serverId: string) => {
try {
await onToggleConnection(serverId);
} catch (err) {
recordConnectError(err instanceof Error ? err.message : String(err));
}
},
[onToggleConnection, recordConnectError],
);
const onReauthenticateFromBanner = useCallback(() => {
if (!reAuthBanner) return;
const serverId = reAuthBanner.serverId;
const bannerKind = reAuthBanner.kind;
setReAuthBanner(null);
// Lost-authorization-state recovery (#1808). The stale half of the flow
// (code verifier without discovery state, possibly a stale registration)
// would make a plain retry fail the same way, so drop the persisted OAuth
// state for this server first, then start a fresh authorization. This
// banner is only raised from the `/oauth/callback` failure path, where the
// session never reached "connected", so connecting is always the right
// toggle direction here.
if (bannerKind === "lost_authorization_state") {
void (async () => {
const server = sessionRef.current.servers.find(
(s) => s.id === serverId,
);
if (server) {
try {
await clearServerOAuthState({
config: server.config,
inspectorClient:
serverId === activeServerId ? inspectorClient : null,
isActiveConnection: serverId === activeServerId,
oauthStorage: webOAuthStorage,
});
} catch (err) {
notifications.show({
title: "Could not clear the stored authorization state",
message: err instanceof Error ? err.message : String(err),
color: "red",
// The banner is already dismissed and the flow is dead, so this
// is the only remaining explanation — don't time it out.
autoClose: false,
});
return;
}
}
await retryConnect(serverId);
})();
return;
}
if (
serverId === activeServerId &&
connectionStatus === "connected" &&
inspectorClient
) {
void (async () => {
const server = servers.find((s) => s.id === serverId);
try {
const authUrl = await inspectorClient.authenticate();
if (authUrl === undefined) {
await inspectorClient.pushRemoteAuthState();
notifications.show({
title: "Authorization restored",
message: authRecoveryRestoredMessage(),
color: "green",
autoClose: 4000,
});
return;
}
prepareOAuthRedirect({
serverId,
authKind: "reauth",
authorizationUrl: authUrl,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
notifications.show({
title: server
? `OAuth authorization failed for "${server.name}"`
: "OAuth authorization failed",
message,
color: "red",
});
}
})();
return;
}
void retryConnect(serverId);
}, [
sessionRef,
reAuthBanner,
retryConnect,
activeServerId,
connectionStatus,
inspectorClient,
servers,
prepareOAuthRedirect,
webOAuthStorage,
setReAuthBanner,
]);
return {
connectedServerId,
connectErrorMessage,
onToggleConnection,
onDisconnect,
onReauthenticateFromBanner,
};
}