Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -12,7 +12,6 @@ import { dirname, join } from "path";
import type {
MessageEntry,
FetchRequestEntry,
MCPServerConfig,
InspectorClientOptions,
InspectorClientEnvironment,
} from "@inspector/core/mcp/index.js";
@ -45,7 +44,15 @@ import { useStderrLog } from "@inspector/core/react/useStderrLog.js";
import {
CallbackNavigation,
MutableRedirectUrlProvider,
isUnauthorizedError,
} from "@inspector/core/auth/index.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import {
buildRunnerClientAuthOptions,
isOAuthCapableServerConfig,
} from "@inspector/core/client/runner.js";
import { formatRunnerOAuthRedirectUrl } from "@inspector/core/auth/node/runner-oauth-callback.js";
import {
createOAuthCallbackServer,
type OAuthCallbackServer,
@ -114,6 +121,7 @@ type FocusArea =
interface AppProps {
mcpServers: Record<string, TuiServer>;
clientConfig: ClientConfig;
clientId?: string;
clientSecret?: string;
clientMetadataUrl?: string;
@ -124,15 +132,9 @@ interface AppProps {
};
}
/** HTTP transports (SSE, streamable-http) can use OAuth. No config gate. */
function isOAuthCapableServer(config: MCPServerConfig | null): boolean {
if (!config) return false;
const c = config as MCPServerConfig & { oauth?: unknown };
return c.type === "sse" || c.type === "streamable-http";
}
function App({
mcpServers,
clientConfig,
clientId,
clientSecret,
clientMetadataUrl,
@ -168,13 +170,13 @@ function App({
logging?: number;
}>({});
const [oauthStatus, setOauthStatus] = useState<
"idle" | "authenticating" | "success" | "error"
"idle" | "authenticating" | "error"
>("idle");
const [oauthMessage, setOauthMessage] = useState<string | null>(null);
const [oauthRevision, setOauthRevision] = useState(0);
const [connectError, setConnectError] = useState<string | null>(null);
const oauthInProgressRef = useRef(false);
const [selectedAuthAction, setSelectedAuthAction] = useState<
"guided" | "quick" | "clear"
>("guided");
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
// Tool test modal state
const [toolTestModal, setToolTestModal] = useState<{
@ -287,23 +289,11 @@ function App({
.map((m) => [m.key, m.value]),
)
: undefined;
const oauthFromSettings =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
savedSettings.oauthScopes)
? {
...(savedSettings.oauthClientId && {
clientId: savedSettings.oauthClientId,
}),
...(savedSettings.oauthClientSecret && {
clientSecret: savedSettings.oauthClientSecret,
}),
...(savedSettings.oauthScopes && {
scope: savedSettings.oauthScopes,
}),
}
: undefined;
const clientAuthOptions = buildRunnerClientAuthOptions(
clientConfig,
savedSettings,
{ clientId, clientSecret, clientMetadataUrl },
);
const opts: InspectorClientOptions = {
environment,
pipeStderr: true,
@ -316,12 +306,15 @@ function App({
defaultMetadata,
}),
...(savedSettings && { serverSettings: savedSettings }),
...clientAuthOptions,
};
if (isOAuthCapableServer(serverConfig)) {
if (isOAuthCapableServerConfig(serverConfig)) {
const redirectUrlProvider =
redirectUrlProvidersRef.current[serverName] ??
(redirectUrlProvidersRef.current[serverName] =
new MutableRedirectUrlProvider());
redirectUrlProvider.redirectUrl =
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
environment.oauth = {
storage: new NodeOAuthStorage(),
navigation: new CallbackNavigation(
@ -329,12 +322,6 @@ function App({
),
redirectUrlProvider,
};
opts.oauth = {
...(oauthFromSettings ?? {}),
...(clientId && { clientId }),
...(clientSecret && { clientSecret }),
...(clientMetadataUrl && { clientMetadataUrl }),
};
}
const client = new InspectorClient(serverConfig, opts);
newClients[serverName] = client;
@ -373,11 +360,19 @@ function App({
setStderrLogStates((prev) => ({ ...prev, ...newStderrLogStates }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [clientId, clientSecret, clientMetadataUrl]);
}, [
clientConfig,
clientId,
clientSecret,
clientMetadataUrl,
callbackUrlConfig,
]);
// Cleanup: destroy managers and disconnect all clients on unmount
useEffect(() => {
return () => {
void callbackServerRef.current?.stop();
callbackServerRef.current = null;
Object.values(managedToolsStates).forEach((manager) => {
manager.destroy();
});
@ -435,7 +430,7 @@ function App({
if (
activeTab === "auth" &&
selectedServerConfig &&
!isOAuthCapableServer(selectedServerConfig)
!isOAuthCapableServerConfig(selectedServerConfig)
) {
setActiveTab("info");
}
@ -455,6 +450,7 @@ function App({
instructions: inspectorInstructions,
connect: connectInspector,
disconnect: disconnectInspector,
lastError: inspectorLastError,
} = useInspectorClient(selectedInspectorClient);
// Log state from managers (per-server)
@ -538,46 +534,23 @@ function App({
selectedManagedPromptsState,
);
// Connect handler - InspectorClient now handles fetching server data automatically
const handleConnect = useCallback(async () => {
if (!selectedServer || !selectedInspectorClient) return;
try {
await connectInspector();
// InspectorClient automatically fetches server data (capabilities, tools, resources, resource templates, prompts, etc.)
// on connect, so we don't need to do anything here
} catch {
// Error handling is done by InspectorClient and will be reflected in status
}
}, [selectedServer, selectedInspectorClient, connectInspector]);
// Disconnect handler
const handleDisconnect = useCallback(async () => {
if (!selectedServer) return;
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
}, [selectedServer, disconnectInspector]);
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
// OAuth Quick Auth (quick execution; callback server + open URL)
const handleQuickAuth = useCallback(async () => {
// Connect — on 401, run OAuth then retry (same pattern as web App.tsx).
const runOAuthAuthentication = useCallback(async () => {
if (
!selectedServer ||
!selectedInspectorClient ||
!selectedServerConfig ||
!isOAuthCapableServer(selectedServerConfig)
!isOAuthCapableServerConfig(selectedServerConfig)
) {
return;
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
setOauthStatus("authenticating");
setOauthMessage(null);
getTuiLogger().info(
{ server: selectedServer },
"OAuth authentication started (Quick Auth)",
"OAuth authentication started",
);
const existing = callbackServerRef.current;
if (existing) {
@ -624,12 +597,7 @@ function App({
if (authUrl !== undefined) {
await flowDone;
}
setOauthStatus("success");
setOauthMessage("OAuth complete. Press C to connect.");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setOauthStatus("error");
setOauthMessage(msg);
setOauthRevision((n) => n + 1);
} finally {
oauthInProgressRef.current = false;
}
@ -640,186 +608,92 @@ function App({
callbackServerBaseOptions,
]);
// OAuth Guided Auth - step-by-step
const handleGuidedStart = useCallback(async () => {
if (
!selectedServer ||
!selectedInspectorClient ||
!selectedServerConfig ||
!isOAuthCapableServer(selectedServerConfig)
) {
const handleConnect = useCallback(async () => {
if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) {
return;
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
setOauthStatus("authenticating");
setOauthMessage(null);
getTuiLogger().info(
{ server: selectedServer },
"OAuth authentication started (Guided Auth)",
);
// Stop any previous callback server (e.g. from failed auth where AS never redirected)
const existing = callbackServerRef.current;
if (existing) {
await existing.stop();
callbackServerRef.current = null;
}
const callbackServer = createOAuthCallbackServer();
callbackServerRef.current = callbackServer;
try {
const { redirectUrl } = await callbackServer.start({
...callbackServerBaseOptions,
onCallback: async (params) => {
try {
await selectedInspectorClient!.completeOAuthFlow(params.code);
setOauthStatus("success");
setOauthMessage("OAuth complete. Press C to connect.");
} catch (err) {
setOauthStatus("error");
setOauthMessage(err instanceof Error ? err.message : String(err));
} finally {
callbackServerRef.current = null;
}
},
onError: (params) => {
setOauthStatus("error");
setOauthMessage(
params.error_description ?? params.error ?? "OAuth error",
);
void callbackServer.stop();
callbackServerRef.current = null;
},
});
const redirectUrlProvider =
redirectUrlProvidersRef.current[selectedServer];
if (redirectUrlProvider) {
redirectUrlProvider.redirectUrl = redirectUrl;
}
await selectedInspectorClient.beginGuidedAuth();
const finishConnect = async () => {
await connectInspector();
setConnectError(null);
setOauthStatus("idle");
} catch (err) {
setOauthStatus("error");
setOauthMessage(err instanceof Error ? err.message : String(err));
} finally {
oauthInProgressRef.current = false;
}
}, [
selectedServer,
selectedInspectorClient,
selectedServerConfig,
callbackServerBaseOptions,
]);
const handleGuidedAdvance = useCallback(async () => {
if (!selectedInspectorClient) return;
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
setOauthStatus("authenticating");
setOauthMessage(null);
getTuiLogger().info(
"OAuth authentication started (Guided Auth advance step)",
);
try {
await selectedInspectorClient.proceedOAuthStep();
const state = selectedInspectorClient.getOAuthFlowState();
if (state?.oauthStep === "authorization_code" && state.authorizationUrl) {
await openUrl(state.authorizationUrl);
}
setOauthStatus("idle");
} catch (err) {
setOauthStatus("error");
setOauthMessage(err instanceof Error ? err.message : String(err));
} finally {
oauthInProgressRef.current = false;
}
}, [selectedInspectorClient]);
const handleRunGuidedToCompletion = useCallback(async () => {
if (
!selectedServer ||
!selectedInspectorClient ||
!selectedServerConfig ||
!isOAuthCapableServer(selectedServerConfig)
) {
return;
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
setOauthStatus("authenticating");
setOauthMessage(null);
getTuiLogger().info(
{ server: selectedServer },
"OAuth authentication started (Run Guided Auth to completion)",
);
const ensureCallbackServer = async () => {
if (callbackServerRef.current) return;
const callbackServer = createOAuthCallbackServer();
callbackServerRef.current = callbackServer;
const { redirectUrl } = await callbackServer.start({
...callbackServerBaseOptions,
onCallback: async (params) => {
try {
await selectedInspectorClient!.completeOAuthFlow(params.code);
setOauthStatus("success");
setOauthMessage("OAuth complete. Press C to connect.");
} catch (err) {
setOauthStatus("error");
setOauthMessage(err instanceof Error ? err.message : String(err));
} finally {
callbackServerRef.current = null;
}
},
onError: (params) => {
setOauthStatus("error");
setOauthMessage(
params.error_description ?? params.error ?? "OAuth error",
);
void callbackServer.stop();
callbackServerRef.current = null;
},
});
const redirectUrlProvider =
redirectUrlProvidersRef.current[selectedServer];
if (redirectUrlProvider) {
redirectUrlProvider.redirectUrl = redirectUrl;
}
setOauthMessage(null);
};
try {
await ensureCallbackServer();
const authUrl = await selectedInspectorClient.runGuidedAuth();
if (authUrl) {
await openUrl(authUrl);
}
setOauthStatus("idle");
await finishConnect();
} catch (err) {
setOauthStatus("error");
setOauthMessage(err instanceof Error ? err.message : String(err));
} finally {
oauthInProgressRef.current = false;
const msg = err instanceof Error ? err.message : String(err);
setConnectError(msg);
if (isEmaClientNotConfiguredError(err)) {
setOauthStatus("error");
setOauthMessage(err.message);
return;
}
if (
isOAuthCapableServerConfig(selectedServerConfig) &&
isUnauthorizedError(err)
) {
try {
setOauthStatus("authenticating");
setOauthMessage(null);
// Tear down the failed handshake transport so the post-OAuth connect
// creates a fresh transport with tokens from storage (same as web,
// which reloads the client after redirect).
await disconnectInspector();
await runOAuthAuthentication();
await finishConnect();
} catch (authErr) {
const authMsg =
authErr instanceof Error ? authErr.message : String(authErr);
setConnectError(authMsg);
if (isEmaClientNotConfiguredError(authErr)) {
setOauthStatus("error");
setOauthMessage(authErr.message);
return;
}
setOauthStatus("error");
setOauthMessage(authMsg);
}
return;
}
}
}, [
selectedServer,
selectedInspectorClient,
selectedServerConfig,
callbackServerBaseOptions,
connectInspector,
disconnectInspector,
runOAuthAuthentication,
]);
const handleClearOAuth = useCallback(() => {
if (selectedInspectorClient) {
selectedInspectorClient.clearOAuthTokens();
setOauthStatus("idle");
setOauthMessage(null);
// Disconnect handler
const handleDisconnect = useCallback(async () => {
if (!selectedServer) return;
await disconnectInspector();
// InspectorClient will update status automatically, and data is preserved
}, [selectedServer, disconnectInspector]);
const handleClearOAuth = useCallback(async () => {
if (!selectedInspectorClient) return;
selectedInspectorClient.clearOAuthTokens();
setOauthStatus("idle");
setOauthMessage(null);
setConnectError(null);
if (inspectorStatus === "connected" || inspectorStatus === "connecting") {
await disconnectInspector();
}
}, [selectedInspectorClient]);
setOauthRevision((n) => n + 1);
}, [selectedInspectorClient, inspectorStatus, disconnectInspector]);
// Build current server state from InspectorClient data (tools from ManagedToolsState)
const currentServerState = useMemo(() => {
if (!selectedServer) return null;
return {
status: inspectorStatus,
error: null, // InspectorClient doesn't track error in state, only emits error events
error: connectError ?? inspectorLastError ?? null,
capabilities: inspectorCapabilities,
serverInfo: inspectorServerInfo,
instructions: inspectorInstructions,
@ -832,6 +706,8 @@ function App({
}, [
selectedServer,
inspectorStatus,
connectError,
inspectorLastError,
inspectorCapabilities,
inspectorServerInfo,
inspectorInstructions,
@ -842,22 +718,6 @@ function App({
inspectorStderrLogs,
]);
// 401 on connect → prompt to authenticate (HTTP servers). Hide during/after auth.
const show401AuthHint = useMemo(() => {
if (inspectorStatus !== "error") return false;
if (oauthStatus === "authenticating" || oauthStatus === "success")
return false;
if (!selectedServerConfig || !isOAuthCapableServer(selectedServerConfig))
return false;
return inspectorFetchRequests.some((r) => r.responseStatus === 401);
}, [
inspectorStatus,
oauthStatus,
selectedServerConfig,
inspectorFetchRequests,
]);
// Helper functions to render details modal content
const renderResourceDetails = (
resource:
| Resource
@ -1220,29 +1080,11 @@ function App({
exit();
}
// G/Q/S: switch to Auth tab (if not already) and select Guided/Quick/Clear
const showAuthTabForAccel =
!!selectedServer &&
!!selectedServerConfig &&
isOAuthCapableServer(selectedServerConfig);
const lower = input.toLowerCase();
if (
showAuthTabForAccel &&
(lower === "g" || lower === "q" || lower === "s")
) {
setActiveTab("auth");
setFocus("tabContentList");
setSelectedAuthAction(
lower === "g" ? "guided" : lower === "q" ? "quick" : "clear",
);
return;
}
// Tab switching with accelerator keys (first character of tab name)
const showAuthTab =
!!selectedServer &&
!!selectedServerConfig &&
isOAuthCapableServer(selectedServerConfig);
isOAuthCapableServerConfig(selectedServerConfig);
const showLoggingTab =
!!selectedServer &&
inspectorClients[selectedServer]?.getServerType() === "stdio";
@ -1265,8 +1107,16 @@ function App({
]),
);
if (tabAccelerators[input.toLowerCase()]) {
setActiveTab(tabAccelerators[input.toLowerCase()]);
setFocus("tabs");
const nextTab = tabAccelerators[input.toLowerCase()]!;
setActiveTab(nextTab);
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
} else if (
activeTab === "auth" &&
showAuthTab &&
input.toLowerCase() === "s"
) {
void handleClearOAuth();
setFocus("tabContentList");
} else if (key.tab && !key.shift) {
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
const focusOrder: FocusArea[] =
@ -1322,7 +1172,7 @@ function App({
const showAuthTab =
!!selectedServer &&
!!selectedServerConfig &&
isOAuthCapableServer(selectedServerConfig);
isOAuthCapableServerConfig(selectedServerConfig);
const showLoggingTab =
!!selectedServer &&
inspectorClients[selectedServer]?.getServerType() === "stdio";
@ -1358,7 +1208,6 @@ function App({
}
// Accelerator keys for connect/disconnect (work from anywhere)
// 'a' switches to Auth tab; use the Auth tab for Quick/Guided auth
if (selectedServer) {
if (
input.toLowerCase() === "c" &&
@ -1543,24 +1392,14 @@ function App({
)}
</Box>
</Box>
{show401AuthHint && (
{oauthStatus === "authenticating" && (
<Box marginTop={1}>
<Text color="yellow">
401 Unauthorized. Press <Text bold>A</Text> to authenticate.
</Text>
<Text dimColor>OAuth: authenticating…</Text>
</Box>
)}
{oauthStatus !== "idle" && (
{oauthStatus === "error" && oauthMessage && (
<Box marginTop={1}>
{oauthStatus === "authenticating" && (
<Text dimColor>OAuth: authenticating…</Text>
)}
{oauthStatus === "success" && oauthMessage && (
<Text color="green">{oauthMessage}</Text>
)}
{oauthStatus === "error" && oauthMessage && (
<Text color="red">OAuth: {oauthMessage}</Text>
)}
<Text color="red">OAuth: {oauthMessage}</Text>
</Box>
)}
</Box>
@ -1577,7 +1416,7 @@ function App({
!!(
selectedServer &&
selectedServerConfig &&
isOAuthCapableServer(selectedServerConfig)
isOAuthCapableServerConfig(selectedServerConfig)
)
}
showLogging={
@ -1623,26 +1462,23 @@ function App({
{activeTab === "auth" &&
selectedServer &&
selectedServerConfig &&
isOAuthCapableServer(selectedServerConfig) ? (
isOAuthCapableServerConfig(selectedServerConfig) ? (
<AuthTab
serverName={selectedServer}
serverConfig={selectedServerConfig}
inspectorClient={selectedInspectorClient}
oauthStatus={oauthStatus}
oauthMessage={oauthMessage}
oauthRevision={oauthRevision}
width={contentWidth}
height={contentHeight}
focused={
focus === "tabContentList" || focus === "tabContentDetails"
}
selectedAction={selectedAuthAction}
onSelectedActionChange={setSelectedAuthAction}
onQuickAuth={handleQuickAuth}
onGuidedStart={handleGuidedStart}
onGuidedAdvance={handleGuidedAdvance}
onRunGuidedToCompletion={handleRunGuidedToCompletion}
onClearOAuth={handleClearOAuth}
isOAuthCapable={true}
onClearOAuth={() => {
void handleClearOAuth();
}}
connectionStatus={inspectorStatus}
/>
) : null}
{activeTab === "resources" &&