- The recovery text told users to move the endpoint to `::1`. A bare IPv6
literal is not a legal URL host — `new URL("http://::1/token")` throws — so
anyone copying that into the Token URL override got a parse error from advice
meant to unblock them. Now `[::1]`, in both the notice and the manual
reproduction guide.
The exemption list still reads `::1`, because that is the host the SDK
compares; only the remedy is bracketed, because that is what a user types.
The comment says so, so neither gets "corrected" into the other.
- Dropped SEP-2207 from all nine files it had reached. I took the label from
the SDK's own source comments, which attribute this check to it — but this
repo already uses SEP-2207 for OIDC refresh / offline_access
(specification/v2_auth_hardening.md, plus an e2e test), so grepping it here
would have turned up two unrelated things. Replaced with plain description
rather than a different SEP number: having guessed wrong once, guessing again
is not the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju
Signed-off-by: cliffhall <cliff@futurescale.com>
1061 lines
46 KiB
TypeScript
1061 lines
46 KiB
TypeScript
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 { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
|
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
|
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
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: Dispatch<SetStateAction<ReAuthBannerState | null>>;
|
|
|
|
/** 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 {
|
|
// Track the just-connected server so its card gets the green highlight +
|
|
// scroll-into-view (#1682). It is a pure function of the two values it reads,
|
|
// so it is derived rather than mirrored into state by an effect: a state copy
|
|
// could only ever lag the status it copies by one committed render, which is
|
|
// a frame of the wrong highlight for no gain.
|
|
const connectedServerId =
|
|
connectionStatus === "connected" ? activeServerId : 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 carries the latest value instead, so client construction reads the
|
|
// current one whichever entry point (connect, deep link, OAuth callback)
|
|
// reached it. Published from a layout effect rather than written during
|
|
// render: every reader is an event handler or a passive effect, both of which
|
|
// run after the commit this writes in, so the value they see is unchanged.
|
|
const sandboxUrlRef = useRef<string | undefined>(undefined);
|
|
useLayoutEffect(() => {
|
|
sandboxUrlRef.current = sandboxUrl;
|
|
}, [sandboxUrl]);
|
|
|
|
const {
|
|
clearResultPanels,
|
|
resetTabUiState,
|
|
resetTaskProgress,
|
|
resetOAuthRecoveryState,
|
|
resetLogLevels,
|
|
closeConnectionInfoModal,
|
|
} = sessionReset;
|
|
|
|
const deepLinkEnsureRef = useRef(false);
|
|
const deepLinkUpdateRef = useRef(false);
|
|
const deepLinkConnectRef = useRef(false);
|
|
|
|
// 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 token endpoint the SDK will not post credentials
|
|
// to. Terminal, so it gets a notice of its own rather than the generic
|
|
// "Failed to connect" toast, whose detail line would be the raw SDK
|
|
// text.
|
|
//
|
|
// The teardown is load-bearing, not tidiness. `connect()` sets its
|
|
// status to `"error"` and dispatches `statusChange` *before* rethrowing
|
|
// (it is not a connect-auth-recovery error), and `InspectorView` pins
|
|
// the monitoring sidebar open on that transition and paints the card
|
|
// red. Returning without it would present this as the failed connect
|
|
// attempt the notice explicitly says it is not. The `authenticate()`
|
|
// arm below already disconnects for the same reason.
|
|
if (findInsecureTokenEndpoint(err)) {
|
|
await client.disconnect().catch(() => {});
|
|
showInsecureTokenEndpointNotice(err, target.name);
|
|
// Clear a banner left by an earlier failure: its Re-authenticate
|
|
// button is just as dead as the one this arm declines to offer, and
|
|
// the user cannot tell which failure it belongs to. Scoped to this
|
|
// server, so an async continuation cannot erase another's.
|
|
setReAuthBanner((prev) =>
|
|
prev && prev.serverId === id ? null : prev,
|
|
);
|
|
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(() => {});
|
|
// The terminal token-endpoint refusal (#2280). The retried `connect()` above can raise the
|
|
// terminal refusal on its own — a satisfied challenge still ends in
|
|
// a token exchange — and reporting that as a failed connect attempt
|
|
// is doubly wrong here: the card goes red and the message is the
|
|
// raw SDK text, on the one path where the Inspector had just told
|
|
// the user the authorization *worked*. Placed after the teardown
|
|
// above, which this arm needs for the same reason the generic one
|
|
// does, and before the flag it must not set.
|
|
if (showInsecureTokenEndpointNotice(recoveryErr, target.name)) {
|
|
// Only this server's banner — an async continuation must not
|
|
// erase one raised for a server the user has since switched to.
|
|
setReAuthBanner((prev) =>
|
|
prev && prev.serverId === id ? null : prev,
|
|
);
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
// See the note on the handshake arm above (#2280). The
|
|
// disconnect already happened at the top of this catch.
|
|
if (showInsecureTokenEndpointNotice(authErr, target.name)) {
|
|
setReAuthBanner((prev) =>
|
|
prev && prev.serverId === id ? null : prev,
|
|
);
|
|
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,
|
|
setReAuthBanner,
|
|
],
|
|
);
|
|
|
|
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) {
|
|
// `onToggleConnection` is a connect, not a state sync: it opens a
|
|
// transport and drives the whole handshake, so the state it sets along
|
|
// the way is a consequence of that I/O rather than something derivable
|
|
// from props. An effect is the right tool here, and the one-shot
|
|
// `deepLinkConnectRef` guard above is what keeps it from re-firing; the
|
|
// rule sees only that an effect called something that reaches setState.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- justified directly above
|
|
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,
|
|
// No RFC 7009 revocation here (#2144): this clears a *half-finished*
|
|
// flow so it can be retried. The authorization never completed, so
|
|
// there is no grant to revoke — and any token still on disk belongs
|
|
// to the very session this recovery is trying to rebuild.
|
|
revoke: false,
|
|
});
|
|
} 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) {
|
|
// The terminal token-endpoint refusal (#2280), on the path a user reaches by *acting*:
|
|
// a connected session raises an ordinary re-auth banner, they click
|
|
// Re-authenticate, and the token exchange is refused. Without this
|
|
// the generic toast below reports it with the raw SDK text — the
|
|
// worst place to lose the guidance, since they have just been told
|
|
// retrying is the fix. No banner clear is needed: this callback
|
|
// already cleared it before starting.
|
|
if (showInsecureTokenEndpointNotice(err, server?.name)) {
|
|
return;
|
|
}
|
|
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,
|
|
};
|
|
}
|