refactor: remove unsafe non-null assertions / double-cast in auth paths

Address the TypeScript-safety review items on the mid-session auth work:

- oauthManager: replace the unguarded `this.oauthConfig.storage!` assertions on
  the EMA re-mint/persist paths with a `requireStorage()` helper that throws a
  clear error; in `getOAuthState` reuse the already-narrowed local `storage`.
- cli: `parseRunnerOAuthCallbackUrl` always returns a config, so make
  `callMethod`'s `callbackUrlConfig` (and the always-passed `cliAuthOverrides`)
  required instead of asserting them non-null at three call sites.
- test-servers/resolve-config: drop the `as unknown as T` double cast; collect
  into a base-shaped array and do a single honest downcast at the return.

No behavior change; validate + coverage green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DZyeWManYTvcXxjcrhxSQc
This commit is contained in:
cliffhall 2026-07-03 13:16:05 -04:00
commit 14404bc405
3 changed files with 29 additions and 14 deletions

View file

@ -71,8 +71,8 @@ async function callMethod(
serverSettings: InspectorServerSettings | undefined,
args: MethodArgs & { method: string },
clientConfig: ClientConfig,
cliAuthOverrides?: RunnerClientConfigOverrides,
callbackUrlConfig?: RunnerOAuthCallbackConfig,
cliAuthOverrides: RunnerClientConfigOverrides,
callbackUrlConfig: RunnerOAuthCallbackConfig,
): Promise<void> {
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = join(__dirname, "../package.json");
@ -93,9 +93,8 @@ async function callMethod(
};
const redirectUrlProvider = new MutableRedirectUrlProvider();
if (isOAuthCapableServerConfig(serverConfig)) {
redirectUrlProvider.redirectUrl = formatRunnerOAuthRedirectUrl(
callbackUrlConfig!,
);
redirectUrlProvider.redirectUrl =
formatRunnerOAuthRedirectUrl(callbackUrlConfig);
environment.oauth = {
storage: new NodeOAuthStorage(),
navigation: new ConsoleNavigation(),
@ -258,14 +257,14 @@ async function callMethod(
inspectorClient,
serverConfig,
redirectUrlProvider,
callbackUrlConfig!,
callbackUrlConfig,
serverSettings,
);
const result = await withCliAuthRecoveryRetry(
inspectorClient,
redirectUrlProvider,
callbackUrlConfig!,
callbackUrlConfig,
serverSettings,
runMethod,
);

View file

@ -10,6 +10,7 @@ import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { mcpAuth } from "../auth/mcpAuth.js";
import type { OAuthStorage } from "../auth/storage.js";
import { parseOAuthState } from "../auth/utils.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import type { ClientConfig } from "../client/types.js";
@ -104,6 +105,19 @@ export class OAuthManager {
return this.params.getServerUrl();
}
/**
* Return the configured OAuth storage, throwing a clear error if it is
* absent. Used on the EMA re-mint/persist paths, which are only reached for
* a fully-configured OAuth-capable server (storage present).
*/
private requireStorage(): OAuthStorage {
const storage = this.oauthConfig.storage;
if (!storage) {
throw new Error("OAuth storage is required for this operation.");
}
return storage;
}
private async createOAuthProvider(): Promise<BaseOAuthClientProvider> {
if (
!this.oauthConfig.storage ||
@ -284,7 +298,7 @@ export class OAuthManager {
requestedScope,
);
if (scopeToPersist) {
await this.oauthConfig.storage!.saveScope(
await this.requireStorage().saveScope(
this.getServerUrl(),
scopeToPersist,
);
@ -422,7 +436,7 @@ export class OAuthManager {
protocol: protocolFromOAuthConfig(this.oauthConfig),
configuredScope: this.oauthConfig.scope,
enterpriseManagedAuth: this.params.enterpriseManagedAuth,
storage: this.oauthConfig.storage!,
storage,
flowState: this.oauthFlowState ?? undefined,
});
}
@ -618,7 +632,7 @@ export class OAuthManager {
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
if (await this.checkAuthChallengeSatisfied(enriched)) {
const minted = await this.oauthConfig.storage!.getTokens(
const minted = await this.requireStorage().getTokens(
this.getServerUrl(),
);
const scopeToPersist = resolvePersistedScopeAfterGrant(
@ -626,7 +640,7 @@ export class OAuthManager {
enriched.authorizationScopes?.join(" "),
);
if (scopeToPersist) {
await this.oauthConfig.storage!.saveScope(
await this.requireStorage().saveScope(
this.getServerUrl(),
scopeToPersist,
);

View file

@ -29,7 +29,7 @@ function resolvePresetRefs<T extends { requiredScopes?: string[] }>(
type: "tool" | "resource" | "resourceTemplate" | "prompt",
): T[] {
if (!refs || refs.length === 0) return [];
const result: T[] = [];
const result: Array<{ requiredScopes?: string[] }> = [];
for (const entry of refs) {
const items = Array.isArray(entry) ? entry : [entry];
for (const ref of items) {
@ -42,11 +42,13 @@ function resolvePresetRefs<T extends { requiredScopes?: string[] }>(
const resolved = resolvePreset(type, presetName, ref.params);
const arr = Array.isArray(resolved) ? resolved : [resolved];
for (const item of arr) {
result.push(mergeRequiredScopes(item as unknown as T, ref));
result.push(mergeRequiredScopes(item, ref));
}
}
}
return result;
// The caller pairs `type` with the matching `T`, so each resolved preset is
// the requested definition kind; TS can't see that correspondence.
return result as T[];
}
/**