chore: close quality-gate coverage gaps (#1689) (#1766)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Closes #1689. Typecheck cli/tui src, add a core/ format+lint gate, and gate core/json + core/client coverage. See PR #1766 for the full review history.
This commit is contained in:
parent
d76c4d5379
commit
373f346e5f
19 changed files with 2210 additions and 467 deletions
|
|
@ -65,11 +65,7 @@ export class AuthChallengeError extends Error {
|
|||
readonly authChallenge: AuthChallenge;
|
||||
readonly status: number;
|
||||
|
||||
constructor(
|
||||
authChallenge: AuthChallenge,
|
||||
status: number,
|
||||
message?: string,
|
||||
) {
|
||||
constructor(authChallenge: AuthChallenge, status: number, message?: string) {
|
||||
super(message ?? `Auth challenge: ${authChallenge.reason}`);
|
||||
this.name = "AuthChallengeError";
|
||||
this.authChallenge = authChallenge;
|
||||
|
|
@ -262,8 +258,7 @@ export function parseAuthChallengeFromError(
|
|||
}
|
||||
|
||||
const status =
|
||||
(err as { status?: number }).status ??
|
||||
(err as { code?: number }).code;
|
||||
(err as { status?: number }).status ?? (err as { code?: number }).code;
|
||||
if (status !== 401 && status !== 403) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -271,8 +266,9 @@ export function parseAuthChallengeFromError(
|
|||
const wwwAuthenticate =
|
||||
authChallenge?.raw?.wwwAuthenticate ??
|
||||
(err as { wwwAuthenticate?: string }).wwwAuthenticate ??
|
||||
(err as { headers?: { get?: (name: string) => string | null } }).headers
|
||||
?.get?.("WWW-Authenticate") ??
|
||||
(
|
||||
err as { headers?: { get?: (name: string) => string | null } }
|
||||
).headers?.get?.("WWW-Authenticate") ??
|
||||
undefined;
|
||||
|
||||
if (!wwwAuthenticate?.length) {
|
||||
|
|
@ -309,7 +305,8 @@ export function isAuthChallengeError(err: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
const authChallenge = (err as { authChallenge?: AuthChallenge }).authChallenge;
|
||||
const authChallenge = (err as { authChallenge?: AuthChallenge })
|
||||
.authChallenge;
|
||||
if (authChallenge?.reason) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -323,8 +320,9 @@ export function isAuthChallengeError(err: unknown): boolean {
|
|||
const wwwAuthenticate =
|
||||
authChallenge?.raw?.wwwAuthenticate ??
|
||||
(err as { wwwAuthenticate?: string }).wwwAuthenticate ??
|
||||
(err as { headers?: { get?: (name: string) => string | null } }).headers
|
||||
?.get?.("WWW-Authenticate") ??
|
||||
(
|
||||
err as { headers?: { get?: (name: string) => string | null } }
|
||||
).headers?.get?.("WWW-Authenticate") ??
|
||||
undefined;
|
||||
|
||||
return wwwAuthenticate !== undefined && wwwAuthenticate.length > 0;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ export {
|
|||
normalizeIdpIssuer,
|
||||
type EmaIdpLoginState,
|
||||
} from "./idpSession.js";
|
||||
export { discoverEmaResourceContext, resolveEmaScopes } from "./resourceContext.js";
|
||||
export {
|
||||
discoverEmaResourceContext,
|
||||
resolveEmaScopes,
|
||||
} from "./resourceContext.js";
|
||||
export {
|
||||
EmaClientNotConfiguredError,
|
||||
emaClientNotConfiguredMessage,
|
||||
|
|
|
|||
|
|
@ -25,13 +25,14 @@ export type { BuildOAuthConnectionStateParams } from "./connection-state.js";
|
|||
export { ensureCimdClientRegistration } from "./cimd.js";
|
||||
|
||||
export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js";
|
||||
export {
|
||||
computeScopeUnion,
|
||||
isStrictScopeSuperset,
|
||||
} from "./scopes.js";
|
||||
export { computeScopeUnion, isStrictScopeSuperset } from "./scopes.js";
|
||||
|
||||
// Storage
|
||||
export type { OAuthStorage, IdpSessionState, SaveClientInformationOptions } from "./storage.js";
|
||||
export type {
|
||||
OAuthStorage,
|
||||
IdpSessionState,
|
||||
SaveClientInformationOptions,
|
||||
} from "./storage.js";
|
||||
export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js";
|
||||
|
||||
// Providers
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
export {
|
||||
NodeOAuthStorage,
|
||||
clearAllOAuthClientState,
|
||||
} from "./storage-node.js";
|
||||
export { NodeOAuthStorage, clearAllOAuthClientState } from "./storage-node.js";
|
||||
export {
|
||||
createOAuthCallbackServer,
|
||||
OAuthCallbackServer,
|
||||
|
|
@ -21,9 +18,7 @@ export {
|
|||
parseRunnerOAuthCallbackUrl,
|
||||
} from "./runner-oauth-callback.js";
|
||||
export type { RunnerOAuthCallbackConfig } from "./runner-oauth-callback.js";
|
||||
export {
|
||||
runRunnerInteractiveOAuth,
|
||||
} from "./runner-interactive-oauth.js";
|
||||
export { runRunnerInteractiveOAuth } from "./runner-interactive-oauth.js";
|
||||
export type {
|
||||
RunRunnerInteractiveOAuthOptions,
|
||||
RunnerInteractiveOAuthClient,
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ export function parseRunnerOAuthCallbackUrl(
|
|||
cliCallbackUrl?: string,
|
||||
): RunnerOAuthCallbackConfig {
|
||||
const raw =
|
||||
cliCallbackUrl?.trim() ||
|
||||
process.env.MCP_OAUTH_CALLBACK_URL?.trim() ||
|
||||
"";
|
||||
cliCallbackUrl?.trim() || process.env.MCP_OAUTH_CALLBACK_URL?.trim() || "";
|
||||
if (!raw) {
|
||||
return {
|
||||
hostname: RUNNER_OAUTH_CALLBACK_DEFAULT_HOSTNAME,
|
||||
|
|
|
|||
|
|
@ -13,10 +13,7 @@
|
|||
* browser side never imports this; it gets values rehydrated into the
|
||||
* `/api/servers` response by the Hono handler.
|
||||
*/
|
||||
import {
|
||||
AsyncEntry,
|
||||
findCredentialsAsync,
|
||||
} from "@napi-rs/keyring";
|
||||
import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring";
|
||||
|
||||
const SERVICE_NAME = "mcp-inspector";
|
||||
|
||||
|
|
|
|||
|
|
@ -84,8 +84,7 @@ export function isStepUpConfirmation(
|
|||
options?: { enterpriseManaged?: boolean },
|
||||
): boolean {
|
||||
return (
|
||||
isStandardOAuthStepUp(challenge, options) ||
|
||||
isEmaStepUp(challenge, options)
|
||||
isStandardOAuthStepUp(challenge, options) || isEmaStepUp(challenge, options)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -124,9 +123,7 @@ export function stepUpFollowUpMessage(options?: {
|
|||
export function stepUpAuthorizeActionLabel(options?: {
|
||||
enterpriseManaged?: boolean;
|
||||
}): string {
|
||||
return options?.enterpriseManaged
|
||||
? "Authorize"
|
||||
: "Authorize (opens browser)";
|
||||
return options?.enterpriseManaged ? "Authorize" : "Authorize (opens browser)";
|
||||
}
|
||||
|
||||
export function stepUpModalTitle(options?: {
|
||||
|
|
@ -198,7 +195,9 @@ export function oauthPreRedirectToastCopy(
|
|||
const name = options.serverName;
|
||||
if (authKind === "step_up") {
|
||||
return {
|
||||
title: name ? `Step-up authorization for "${name}"` : "Step-up authorization",
|
||||
title: name
|
||||
? `Step-up authorization for "${name}"`
|
||||
: "Step-up authorization",
|
||||
message: "Redirecting to authorize additional permissions…",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ export const CIMD_METADATA_URL_PATH_ERROR =
|
|||
* Returns undefined when the value is valid; empty strings are not flagged here
|
||||
* (required-field gating lives in {@link canPersistClientSettingsDraft}).
|
||||
*/
|
||||
export function getCimdClientMetadataUrlError(value: string): string | undefined {
|
||||
export function getCimdClientMetadataUrlError(
|
||||
value: string,
|
||||
): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
if (!isAbsoluteHttpUrl(trimmed)) {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@
|
|||
* Remote HTTP load/save for client.json via /api/storage/client.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseClientConfig,
|
||||
serializeClientConfig,
|
||||
} from "./config-parse.js";
|
||||
import { parseClientConfig, serializeClientConfig } from "./config-parse.js";
|
||||
import type { ClientConfig } from "./types.js";
|
||||
|
||||
export interface RemoteClientConfigOptions {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@
|
|||
* for Node runners (TUI, CLI).
|
||||
*/
|
||||
|
||||
import { KeyringSecretStore } from "../auth/node/secret-store.js";
|
||||
import type { InspectorClientOptions, InspectorServerSettings } from "../mcp/types.js";
|
||||
import {
|
||||
KeyringSecretStore,
|
||||
type SecretStore,
|
||||
} from "../auth/node/secret-store.js";
|
||||
import type {
|
||||
InspectorClientOptions,
|
||||
InspectorServerSettings,
|
||||
} from "../mcp/types.js";
|
||||
import { loadClientConfig } from "./config.js";
|
||||
import type { ClientConfig } from "./types.js";
|
||||
import {
|
||||
|
|
@ -15,6 +21,9 @@ import {
|
|||
export interface LoadRunnerClientConfigOptions {
|
||||
/** Explicit path from `--client-config` (or MCP_CLIENT_CONFIG_PATH when unset). */
|
||||
clientConfigPath?: string;
|
||||
/** Secret store for the IdP clientSecret; defaults to the OS keychain. Tests
|
||||
* inject an in-memory store for determinism. */
|
||||
secretStore?: SecretStore;
|
||||
}
|
||||
|
||||
/** Load install-level client.json with keychain-backed IdP secrets. */
|
||||
|
|
@ -25,7 +34,7 @@ export async function loadRunnerClientConfig(
|
|||
options?.clientConfigPath?.trim() ||
|
||||
process.env.MCP_CLIENT_CONFIG_PATH?.trim() ||
|
||||
undefined;
|
||||
const secretStore = new KeyringSecretStore();
|
||||
const secretStore = options?.secretStore ?? new KeyringSecretStore();
|
||||
return loadClientConfig({ filePath: customPath, secretStore });
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +62,10 @@ export function buildRunnerClientAuthOptions(
|
|||
cliOverrides?: RunnerClientConfigOverrides,
|
||||
): Pick<
|
||||
InspectorClientOptions,
|
||||
"oauth" | "enterpriseManagedAuth" | "installEnterpriseManagedAuth" | "directAuthRecovery"
|
||||
| "oauth"
|
||||
| "enterpriseManagedAuth"
|
||||
| "installEnterpriseManagedAuth"
|
||||
| "directAuthRecovery"
|
||||
> {
|
||||
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
|
||||
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
||||
|
|
@ -84,9 +96,7 @@ export function buildRunnerClientAuthOptions(
|
|||
cliOverrides?.clientMetadataUrl?.trim() || activeCimdUrl;
|
||||
|
||||
const oauthFromCli =
|
||||
cliOverrides?.clientId ||
|
||||
cliOverrides?.clientSecret ||
|
||||
clientMetadataUrl
|
||||
cliOverrides?.clientId || cliOverrides?.clientSecret || clientMetadataUrl
|
||||
? {
|
||||
...(cliOverrides?.clientId && { clientId: cliOverrides.clientId }),
|
||||
...(cliOverrides?.clientSecret && {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ export interface ClientConfig {
|
|||
}
|
||||
|
||||
/** True when install-level EMA IdP config is active (not just stored). */
|
||||
export function isEnterpriseManagedAuthEnabled(
|
||||
config: ClientConfig,
|
||||
): boolean {
|
||||
export function isEnterpriseManagedAuthEnabled(config: ClientConfig): boolean {
|
||||
const ema = config.enterpriseManagedAuth;
|
||||
if (!ema?.idp) return false;
|
||||
return ema.enabled !== false;
|
||||
|
|
|
|||
|
|
@ -32,9 +32,7 @@ export function isOAuthCapableServerType(type: ServerType): boolean {
|
|||
* MCP server URL used as the OAuth storage key (includes path, for discovery).
|
||||
* Undefined for stdio transports.
|
||||
*/
|
||||
export function getOAuthServerUrl(
|
||||
config: MCPServerConfig,
|
||||
): string | undefined {
|
||||
export function getOAuthServerUrl(config: MCPServerConfig): string | undefined {
|
||||
if (config.type === "sse" || config.type === "streamable-http") {
|
||||
return config.url;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1827,9 +1827,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// a future modern-family revision would negotiate a different string, and
|
||||
// the two must not disagree. The raw channel only runs on a connected modern
|
||||
// session, so this is always set; the constant is a defensive fallback.
|
||||
/* v8 ignore next -- fallback only if getProtocolVersion() is unset, which
|
||||
can't happen on the connected modern session this runs on. */
|
||||
const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION;
|
||||
/* v8 ignore start -- fallback only if getProtocolVersion() is unset, which
|
||||
can't happen on the connected modern session this runs on. Bracketed so
|
||||
the ignore is reflow-proof however prettier splits the statement. */
|
||||
const protocolVersion =
|
||||
this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION;
|
||||
/* v8 ignore stop */
|
||||
return {
|
||||
...params,
|
||||
_meta: {
|
||||
|
|
@ -1861,9 +1864,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
...message,
|
||||
result: {
|
||||
resultType: "complete",
|
||||
content: [
|
||||
{ type: "text", text: `Modern task ${task.taskId} created` },
|
||||
],
|
||||
content: [{ type: "text", text: `Modern task ${task.taskId} created` }],
|
||||
_meta: { [MODERN_TASK_HANDLE_META]: task },
|
||||
},
|
||||
};
|
||||
|
|
@ -1906,7 +1907,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
const raw = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRawWireRequests.delete(id);
|
||||
reject(new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`));
|
||||
reject(
|
||||
new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pendingRawWireRequests.set(id, { resolve, reject, timer });
|
||||
transport.send(message).catch((err: unknown) => {
|
||||
|
|
@ -4443,7 +4446,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.modernReconnectTimer = undefined;
|
||||
// Disconnect/unsubscribe may have raced the timer — bail if the reconnect
|
||||
// is no longer wanted.
|
||||
if (isTerminalStatus(this.status) || this.subscribedResources.size === 0) {
|
||||
if (
|
||||
isTerminalStatus(this.status) ||
|
||||
this.subscribedResources.size === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.refreshModernSubscription(true).catch(() =>
|
||||
|
|
|
|||
|
|
@ -32,4 +32,4 @@ export function createAuthChallengeInterceptFetch(
|
|||
`MCP auth challenge (${response.status})`,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import type { SecretStore } from "../../auth/node/secret-store.js";
|
||||
import {
|
||||
expectedSecretFields,
|
||||
mergeSecretsIntoStored,
|
||||
} from "../serverList.js";
|
||||
import { expectedSecretFields, mergeSecretsIntoStored } from "../serverList.js";
|
||||
import type { MCPConfig } from "../types.js";
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
// A status update is ignored when the task was dismissed (clearCompleted)
|
||||
// or when the user cancelled it and the update isn't itself "cancelled" —
|
||||
// the deliberate cancel wins over a late completed/working update.
|
||||
const shouldIgnoreUpdate = (taskId: string, status: Task["status"]): boolean =>
|
||||
const shouldIgnoreUpdate = (
|
||||
taskId: string,
|
||||
status: Task["status"],
|
||||
): boolean =>
|
||||
this.dismissedTaskIds.has(taskId) ||
|
||||
(this.cancelledTaskIds.has(taskId) && status !== "cancelled");
|
||||
const onTaskStatusChange = (
|
||||
|
|
@ -200,7 +203,9 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
* whole refresh. No known tasks ⇒ a no-op refresh (the correct "no server
|
||||
* list" semantics).
|
||||
*/
|
||||
private async refreshModern(client: InspectorClientProtocol): Promise<Task[]> {
|
||||
private async refreshModern(
|
||||
client: InspectorClientProtocol,
|
||||
): Promise<Task[]> {
|
||||
const ids = this.tasks
|
||||
.map((t) => t.taskId)
|
||||
.filter((id) => !this.dismissedTaskIds.has(id));
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ import type {
|
|||
InspectorResourceSubscription,
|
||||
ResourceSubscriptionStreamState,
|
||||
} from "../types.js";
|
||||
import { isTerminalStatus, INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js";
|
||||
import {
|
||||
isTerminalStatus,
|
||||
INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
} from "../types.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TypedEventTarget,
|
||||
|
|
|
|||
|
|
@ -583,8 +583,7 @@ export const MODERN_LOG_LEVELS: ModernLogLevel[] = [
|
|||
/** Runtime guard for the {@link ModernLogLevel} literal (hand-edited files). */
|
||||
export function isModernLogLevel(value: unknown): value is ModernLogLevel {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(MODERN_LOG_LEVELS as string[]).includes(value)
|
||||
typeof value === "string" && (MODERN_LOG_LEVELS as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,9 +74,7 @@ export function useClientSettingsDraft<T>({
|
|||
const prev = latestValuesRef.current;
|
||||
if (prev === null) return;
|
||||
const resolved =
|
||||
typeof next === "function"
|
||||
? (next as (prev: T) => T)(prev)
|
||||
: next;
|
||||
typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
|
||||
latestValuesRef.current = resolved;
|
||||
setDraft(resolved);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
|
|
|
|||
Loading…
Reference in a new issue