inspector/core/mcp/node/servers.ts
Bob Dickinson 13dafe457b
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>
2026-06-29 15:35:51 -04:00

172 lines
6 KiB
TypeScript

import {
KeyringSecretStore,
type SecretStore,
} from "../../auth/node/secret-store.js";
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
import { mcpConfigToServerEntries } from "../serverList.js";
import {
applyOverrides,
resolveServerConfigs,
resolveServerSource,
serverSourceConflict,
readServerListFile,
hasAdHocServerOptions,
withDefaultCatalogPath,
type ServerConfigOptions,
} from "./config.js";
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
/**
* A server resolved from a catalog/config file or an ad-hoc target, paired with
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
* `config` is the SDK-facing transport config; `settings` is the v2
* `InspectorServerSettings` model (post-#1358 top-level shape).
*/
export type ResolvedServer = {
config: MCPServerConfig;
settings?: InspectorServerSettings;
};
/** Loader options: the shared source flags plus a single `--header` set that is
* broadcast into every resolved server's settings. */
export type ServerLoadOptions = ServerConfigOptions & {
headers?: Record<string, string>;
/** Test injection; defaults to {@link KeyringSecretStore} for catalog/config loads. */
secretStore?: SecretStore;
};
/**
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
* other fields defaulted). Returns undefined when no headers are given so it can
* be used as a no-op base in `mergeSettings`.
*/
export function headersToServerSettings(
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
if (!headers || Object.keys(headers).length === 0) {
return undefined;
}
return {
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
roots: [],
};
}
/**
* Overlay CLI `--header` values onto the settings lifted from the file. Only the
* `headers` field is overridden — timeouts, OAuth, and the rest of the file's
* settings are preserved.
*/
function mergeSettings(
base: InspectorServerSettings | undefined,
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
const fromHeaders = headersToServerSettings(headers);
if (!fromHeaders) return base;
if (!base) return fromHeaders;
return { ...base, headers: fromHeaders.headers };
}
/**
* Resolve every server from a catalog/config file (or a single ad-hoc target)
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
* and TUI so both apply the same file→settings resolution (issue #1482).
*
* Applies the default writable catalog when no source/ad-hoc target is given,
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
* writable catalog on first run (a missing read-only `--config` still errors).
*/
export async function loadServerEntries(
serverOptions: ServerLoadOptions,
): Promise<Record<string, ResolvedServer>> {
serverOptions = withDefaultCatalogPath(serverOptions);
const conflict = serverSourceConflict({
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
hasConfig: Boolean(serverOptions.configPath?.trim()),
hasAdHoc: hasAdHocServerOptions(serverOptions),
});
if (conflict) {
throw new Error(conflict);
}
const source = resolveServerSource(serverOptions);
if (source) {
let config = readServerListFile(source.path, source.writable);
const secretStore = serverOptions.secretStore ?? new KeyringSecretStore();
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
const entries = mcpConfigToServerEntries(config);
const result: Record<string, ResolvedServer> = {};
for (const entry of entries) {
result[entry.name] = {
config: applyOverrides(entry.config, {
env: serverOptions.env,
cwd: serverOptions.cwd,
}),
// Deliberate broadcast: a single `--header` set is merged into EVERY
// server in the catalog/config (fine for the common single-server case;
// for multi-server files, prefer per-server headers in the file itself).
settings: mergeSettings(entry.settings, serverOptions.headers),
};
}
return result;
}
const configs = resolveServerConfigs(serverOptions, "multi");
if (configs.length === 0) {
throw new Error(
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
);
}
return {
default: {
config: configs[0]!,
settings: headersToServerSettings(serverOptions.headers),
},
};
}
/**
* Pick a single resolved server for runners that connect to exactly one (the
* CLI). With `serverName`, returns that entry or errors listing the available
* names; otherwise requires exactly one server in the source.
*
* The unknown-name error is source-agnostic ("not found" rather than "not found
* in config file") because `entries` may come from a file *or* a single ad-hoc
* target — e.g. `--server foo` alongside a positional command resolves to just
* `{ default }`, where "in config file" would be misleading.
*/
export function selectServerEntry(
entries: Record<string, ResolvedServer>,
serverName?: string,
): ResolvedServer {
const names = Object.keys(entries);
if (serverName) {
const entry = entries[serverName];
if (!entry) {
throw new Error(
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
);
}
return entry;
}
if (names.length === 0) {
throw new Error("No servers found in config file");
}
if (names.length > 1) {
throw new Error(
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
);
}
return entries[names[0]!]!;
}