All of the mid-session and step-up auth work

This commit is contained in:
Bob Dickinson 2026-07-02 16:35:31 -07:00
commit 562bdd00f1
12 changed files with 11437 additions and 1212 deletions

View file

@ -118,11 +118,26 @@ import {
UrlElicitationLoopError,
} from "./urlElicitation.js";
import { ToolCallCancelledError } from "./toolCallCancelledError.js";
import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js";
import type {
OAuthConnectionState,
OAuthFlowState,
OAuthStep,
} from "../auth/types.js";
import {
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
isAuthChallengeError,
isConnectAuthRecoveryError,
parseAuthChallengeFromError,
type AuthChallenge,
type AuthChallengeOutcome,
type HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
import { createFetchTracker } from "./fetchTracking.js";
import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js";
import { RemoteClientTransport } from "./remote/remoteClientTransport.js";
/** Internal record for a receiver task (server polls us for status/result). */
interface ReceiverTaskRecord {
@ -181,6 +196,8 @@ export class InspectorClient extends InspectorClientEventTarget {
private baseTransport: Transport | null = null;
/** True when the cached transport was built with an OAuth authProvider attached. */
private transportHasAuthProvider = false;
/** Dedupes concurrent ambient auth challenges (reason + scopes). */
private ambientAuthChallengeInFlight = new Map<string, Promise<void>>();
private pipeStderr: boolean;
private initialLoggingLevel?: LoggingLevel;
private sample: boolean;
@ -249,6 +266,14 @@ export class InspectorClient extends InspectorClientEventTarget {
// Session ID (for OAuth state and saveSession event; persistence is in FetchRequestLogState)
private sessionId?: string;
private transportConfig: MCPServerConfig;
/** null until first transport is built; then true for in-process OAuth runners. */
private directAuthRecoveryActive: boolean | null = null;
/**
* Opt-in from {@link InspectorClientOptions.directAuthRecovery}: when true and
* the live transport is direct (not {@link RemoteClientTransport}), RPCs use
* fetch intercept + {@link withDirectAuthRecovery}.
*/
private readonly directAuthRecovery: boolean;
constructor(
transportConfig: MCPServerConfig,
@ -276,6 +301,7 @@ export class InspectorClient extends InspectorClientEventTarget {
? options.defaultMetadata
: undefined;
this.serverSettings = options.serverSettings;
this.directAuthRecovery = options.directAuthRecovery ?? false;
// Only set roots if explicitly provided (even if empty array) - this enables roots capability
this.roots = options.roots;
// Initialize listChangedNotifications config (default: all enabled)
@ -426,7 +452,10 @@ export class InspectorClient extends InspectorClientEventTarget {
};
this.dispatchTypedEvent("message", entry);
},
trackNotification: (message: JSONRPCNotification, origin: MessageOrigin) => {
trackNotification: (
message: JSONRPCNotification,
origin: MessageOrigin,
) => {
const entry: MessageEntry = {
id: crypto.randomUUID(),
timestamp: new Date(),
@ -761,12 +790,40 @@ export class InspectorClient extends InspectorClientEventTarget {
await oauthManager.createOAuthProviderForTransport();
}
}
if (
this.directAuthRecovery &&
this.directAuthRecoveryActive !== false &&
this.isHttpOAuthConfig() &&
oauthManager &&
transportOptions.authProvider
) {
transportOptions.interceptAuthChallenges = true;
}
this.transportHasAuthProvider = !!transportOptions.authProvider;
const { transport: baseTransport } = this.transportClientFactory(
this.transportConfig,
transportOptions,
);
this.baseTransport = baseTransport;
if (this.directAuthRecovery) {
this.directAuthRecoveryActive = !(
baseTransport instanceof RemoteClientTransport
);
}
if (
baseTransport instanceof RemoteClientTransport &&
oauthManager &&
this.isHttpOAuthConfig()
) {
baseTransport.setAuthRecovery({
handleAuthChallenge: (challenge, options) =>
oauthManager.handleAuthChallenge(challenge, options),
pushAuthState: () => this.pushRemoteAuthState(),
});
baseTransport.setOnAuthChallenge((challenge) => {
void this.handleAmbientAuthChallenge(challenge);
});
}
const messageTracking = this.createMessageTrackingCallbacks();
this.transport = new MessageTrackingTransport(
baseTransport,
@ -789,38 +846,38 @@ export class InspectorClient extends InspectorClientEventTarget {
// connect() starts clean and the upstream socket isn't left hanging.
const connectTimeoutMs = this.serverSettings?.connectionTimeout ?? 0;
const connectPromise = this.client.connect(this.transport);
if (connectTimeoutMs > 0) {
// Absorb any late rejection from `connectPromise` — when the timeout
// wins the race and `disconnect()` tears the transport down, real
// transports (SSE / streamable-http) reject the in-flight handshake
// *after* Promise.race has already settled. Without a handler here
// Node emits an unhandledRejection warning (and in test runs vitest
// can surface it as a suite failure). Only needed on the race path;
// the await-only branch below propagates rejection cleanly through
// the outer try/catch.
connectPromise.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Connection timed out after ${connectTimeoutMs} ms`,
const runConnect = async (): Promise<void> => {
if (connectTimeoutMs > 0) {
connectPromise.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`Connection timed out after ${connectTimeoutMs} ms`,
),
),
),
connectTimeoutMs,
);
});
try {
await Promise.race([connectPromise, timeoutPromise]);
} catch (err) {
await this.disconnect().catch(() => {});
throw err;
} finally {
if (timer) clearTimeout(timer);
connectTimeoutMs,
);
});
try {
await Promise.race([connectPromise, timeoutPromise]);
} finally {
if (timer) clearTimeout(timer);
}
} else {
await connectPromise;
}
} else {
await connectPromise;
};
try {
await this.invokeMcpClient(runConnect);
} catch (err) {
if (connectTimeoutMs > 0) {
await this.disconnect().catch(() => {});
}
throw err;
}
this.status = "connected";
this.dispatchTypedEvent("statusChange", this.status);
@ -1116,8 +1173,10 @@ export class InspectorClient extends InspectorClientEventTarget {
// and routing work, and we inject the caller's progressToken into dispatched events.
}
} catch (error) {
this.status = "error";
this.dispatchTypedEvent("statusChange", this.status);
if (!isConnectAuthRecoveryError(error)) {
this.status = "error";
this.dispatchTypedEvent("statusChange", this.status);
}
if (this.baseTransport && !this.transportHasAuthProvider) {
await this.dropCachedTransport();
}
@ -1221,7 +1280,10 @@ export class InspectorClient extends InspectorClientEventTarget {
this.instructions = undefined;
this.protocolVersion = undefined;
this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples);
this.dispatchTypedEvent("pendingElicitationsChange", this.pendingElicitations);
this.dispatchTypedEvent(
"pendingElicitationsChange",
this.pendingElicitations,
);
this.dispatchTypedEvent("capabilitiesChange", this.capabilities);
this.dispatchTypedEvent("serverInfoChange", this.serverInfo);
this.dispatchTypedEvent("instructionsChange", this.instructions);
@ -1558,9 +1620,11 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.client.listTools(
params,
this.getRequestOptions(metadata?.progressToken),
const response = await this.invokeMcpClient(() =>
this.client!.listTools(
params,
this.getRequestOptions(metadata?.progressToken),
),
);
const tools = [...(response.tools || [])];
return { tools, nextCursor: response.nextCursor };
@ -1803,13 +1867,17 @@ export class InspectorClient extends InspectorClientEventTarget {
// Both branches yield a CallToolResult: request() parsed it with
// CallToolResultSchema above, callTool() returns the same shape — so the
// `as CallToolResult` casts below are safe.
const result = options?.skipOutputValidation
? await client.request(
{ method: "tools/call", params: callParams },
CallToolResultSchema,
requestOptions,
)
: await client.callTool(callParams, undefined, requestOptions);
const result = await this.invokeMcpClient(
() =>
options?.skipOutputValidation
? client.request(
{ method: "tools/call", params: callParams },
CallToolResultSchema,
requestOptions,
)
: client.callTool(callParams, undefined, requestOptions),
{ method: "tools/call", toolName: tool.name },
);
// On the bypass path the result was delivered without the SDK's strict
// output validation. Run that check ourselves, non-fatally, so callers can
@ -2197,9 +2265,11 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.client.listResources(
params,
this.getRequestOptions(metadata?.progressToken),
const response = await this.invokeMcpClient(() =>
this.client!.listResources(
params,
this.getRequestOptions(metadata?.progressToken),
),
);
return {
resources: response.resources || [],
@ -2225,9 +2295,13 @@ export class InspectorClient extends InspectorClientEventTarget {
uri,
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
const result = await this.client.readResource(
params,
this.getRequestOptions(metadata?.progressToken),
const result = await this.invokeMcpClient(
() =>
this.client!.readResource(
params,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "resources/read" },
);
const invocation: ResourceReadInvocation = {
result,
@ -2317,9 +2391,13 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.client.listResourceTemplates(
params,
this.getRequestOptions(metadata?.progressToken),
const response = await this.invokeMcpClient(
() =>
this.client!.listResourceTemplates(
params,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "resources/templates/list" },
);
return {
resourceTemplates: response.resourceTemplates || [],
@ -2345,9 +2423,11 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
...(cursor ? { cursor } : {}),
};
const response = await this.client.listPrompts(
params,
this.getRequestOptions(metadata?.progressToken),
const response = await this.invokeMcpClient(() =>
this.client!.listPrompts(
params,
this.getRequestOptions(metadata?.progressToken),
),
);
return {
prompts: response.prompts || [],
@ -2380,9 +2460,13 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
const result = await this.client.getPrompt(
params,
this.getRequestOptions(metadata?.progressToken),
const result = await this.invokeMcpClient(
() =>
this.client!.getPrompt(
params,
this.getRequestOptions(metadata?.progressToken),
),
{ method: "prompts/get", toolName: name },
);
const invocation: PromptGetInvocation = {
@ -2437,9 +2521,17 @@ export class InspectorClient extends InspectorClientEventTarget {
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
};
const response = await this.client.complete(
params,
this.getRequestOptions(metadata?.progressToken),
const response = await this.invokeMcpClient(
() =>
this.client!.complete(
params,
this.getRequestOptions(metadata?.progressToken),
),
{
method: "completion/complete",
toolName:
ref.type === "ref/prompt" ? ref.name : ref.uri,
},
);
return {
@ -2721,10 +2813,239 @@ export class InspectorClient extends InspectorClientEventTarget {
}
/**
* Completes OAuth flow with authorization code from the redirect callback.
* Satisfy a mid-session auth challenge (token refresh, step-up, or interactive re-auth).
*/
async completeOAuthFlow(authorizationCode: string): Promise<void> {
return this.ensureOAuthManager().completeOAuthFlow(authorizationCode);
async handleAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome> {
return this.ensureOAuthManager().handleAuthChallenge(challenge, options);
}
/**
* Re-read OAuth storage and test whether a challenge is already satisfied.
* See {@link OAuthManager.checkAuthChallengeSatisfied}.
*/
async checkAuthChallengeSatisfied(
challenge: AuthChallenge,
): Promise<boolean> {
return this.ensureOAuthManager().checkAuthChallengeSatisfied(challenge);
}
/**
* Push recovered OAuth auth state to the remote backend (same MCP session).
*/
async pushRemoteAuthState(): Promise<void> {
if (!(this.baseTransport instanceof RemoteClientTransport)) {
return;
}
await this.baseTransport.pushAuthState();
}
/**
* Handle an ambient (SSE) auth challenge when no command-scoped send is active.
* Recovers session tokens on the remote backend; does not retry RPCs.
*/
async handleAmbientAuthChallenge(challenge: AuthChallenge): Promise<void> {
const key = this.ambientAuthChallengeKey(challenge);
const existing = this.ambientAuthChallengeInFlight.get(key);
if (existing) {
return existing;
}
const promise = this.runAmbientAuthChallenge(challenge);
this.ambientAuthChallengeInFlight.set(key, promise);
try {
await promise;
} finally {
if (this.ambientAuthChallengeInFlight.get(key) === promise) {
this.ambientAuthChallengeInFlight.delete(key);
}
}
}
private async runAmbientAuthChallenge(
challenge: AuthChallenge,
): Promise<void> {
try {
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
const oauthManager = this.oauthManager;
if (!oauthManager) {
return;
}
const outcome = await oauthManager.handleAuthChallenge(challenge);
if (outcome.kind === "satisfied") {
if (this.baseTransport instanceof RemoteClientTransport) {
await this.pushRemoteAuthState();
} else {
await this.reconnectAfterAuthRecovery();
}
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
} else if (outcome.kind === "step_up_confirm") {
this.dispatchTypedEvent("authChallengeInteractive", {
challenge: outcome.challenge,
authorizationUrl: EMA_STEP_UP_PENDING_URL,
});
} else if (outcome.kind === "interactive") {
this.dispatchTypedEvent("authChallengeInteractive", {
challenge: outcome.challenge,
authorizationUrl: outcome.authorizationUrl,
});
} else {
this.dispatchTypedEvent("oauthError", { error: outcome.error });
}
} catch (error) {
this.dispatchTypedEvent("oauthError", {
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
private ambientAuthChallengeKey(challenge: AuthChallenge): string {
const requiredScopes = [...(challenge.requiredScopes ?? [])]
.sort()
.join(" ");
const authorizationScopes = [...(challenge.authorizationScopes ?? [])]
.sort()
.join(" ");
return `${challenge.reason}:${requiredScopes}:${authorizationScopes}`;
}
/**
* Full disconnect + reconnect after ambient auth recovery on direct transports.
*/
private async reconnectAfterAuthRecovery(): Promise<void> {
await this.disconnect().catch(() => {});
await this.dropCachedTransport();
await this.connect();
}
/** Direct (non-remote) OAuth transports recover via fetch intercept + handleAuthChallenge. */
private usesDirectAuthRecovery(): boolean {
return this.directAuthRecovery && this.directAuthRecoveryActive === true;
}
private async withDirectAuthRecovery<T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
attempt = 0,
): Promise<T> {
try {
return await operation();
} catch (err) {
if (attempt >= 1 || !this.usesDirectAuthRecovery()) {
throw err;
}
if (!isAuthChallengeError(err)) {
throw err;
}
const challenge = parseAuthChallengeFromError(err, context);
if (!challenge) {
throw err;
}
if (context?.method || context?.toolName) {
this.dispatchTypedEvent("authChallengeCommand", { challenge });
} else {
this.dispatchTypedEvent("authChallengeAmbient", { challenge });
}
const outcome = await this.handleAuthChallenge(challenge);
if (outcome.kind === "satisfied") {
// Reconnect aborts activeToolCallAbortController; clear it so callTool
// retries are not immediately rejected with "Disconnected".
if (this.activeToolCallAbortController) {
this.activeToolCallAbortController = undefined;
}
await this.reconnectAfterAuthRecovery();
this.dispatchTypedEvent("authChallengeRecovered", { challenge });
return this.withDirectAuthRecovery(operation, context, attempt + 1);
}
if (outcome.kind === "step_up_confirm") {
throw new AuthRecoveryRequiredError(
EMA_STEP_UP_PENDING_URL,
outcome.challenge,
{ emaStepUpConfirm: true },
);
}
if (outcome.kind === "interactive") {
throw new AuthRecoveryRequiredError(
outcome.authorizationUrl,
outcome.challenge,
);
}
this.dispatchTypedEvent("oauthError", { error: outcome.error });
throw outcome.error;
}
}
private async invokeMcpClient<T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
): Promise<T> {
if (!this.usesDirectAuthRecovery()) {
return operation();
}
return this.withDirectAuthRecovery(operation, context);
}
/**
* Completes OAuth flow with authorization code from the redirect callback.
* Direct transports reconnect after token exchange so the live MCP session
* picks up the new Bearer token (mirrors silent recovery reconnect).
*/
async completeOAuthFlow(authorizationCode: string, iss?: string): Promise<void> {
await this.ensureOAuthManager().completeOAuthFlow(authorizationCode, iss);
if (this.usesDirectAuthRecovery()) {
await this.reconnectAfterAuthRecovery();
}
}
/**
* Navigate to the authorization server for interactive recovery.
*/
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
return this.ensureOAuthManager().beginInteractiveAuthorization(
authorizationUrl,
);
}
/** Remote Hono session id when using {@link RemoteClientTransport}. */
getRemoteBackendSessionId(): string | undefined {
if (this.baseTransport instanceof RemoteClientTransport) {
return this.baseTransport.getRemoteBackendSessionId();
}
return undefined;
}
/**
* Finish OAuth after a full-page redirect and reconnect (or reattach) the MCP session.
*/
async resumeAfterOAuth(
authorizationCode: string,
options?: { remoteSessionId?: string },
): Promise<void> {
await this.completeOAuthFlow(authorizationCode);
const remoteSessionId = options?.remoteSessionId;
const transport = this.baseTransport;
if (remoteSessionId && transport instanceof RemoteClientTransport) {
try {
await transport.attachToSession(remoteSessionId);
await transport.pushAuthState();
if (this.status !== "connected") {
await this.connect();
}
return;
} catch {
// Session expired during OAuth round trip — fall back to fresh connect.
}
}
if (this.status !== "connected") {
await this.connect();
}
}
/**

View file

@ -39,6 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { AuthChallenge } from "../auth/challenge.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
@ -148,6 +149,17 @@ export interface InspectorClientEventMap {
oauthComplete: { tokens: OAuthTokens };
oauthAuthorizationRequired: { url: URL };
oauthError: { error: Error };
/** Ambient (SSE) auth challenge while no command-scoped send is active. */
authChallengeAmbient: { challenge: AuthChallenge };
/** Command-scoped direct-transport auth challenge (no ambient toast). */
authChallengeCommand: { challenge: AuthChallenge };
/** Ambient auth recovery completed (remote auth state pushed). */
authChallengeRecovered: { challenge: AuthChallenge };
/** Interactive auth required; App orchestrates redirect (step-up modal or reauth). */
authChallengeInteractive: {
challenge: AuthChallenge;
authorizationUrl: URL;
};
}
/**

View file

@ -0,0 +1,34 @@
import {
AuthChallengeError,
parseAuthChallengeFromResponse,
} from "../../auth/challenge.js";
/**
* Wrap fetch so MCP HTTP 401/403 responses become {@link AuthChallengeError}
* before the SDK invokes `auth()` on a frozen remote token provider.
*/
export function createAuthChallengeInterceptFetch(
baseFetch: typeof fetch,
): typeof fetch {
return async (input, init) => {
const response = await baseFetch(input, init);
if (response.status !== 401 && response.status !== 403) {
return response;
}
const challenge = parseAuthChallengeFromResponse(response);
if (!challenge) {
return response;
}
// Release the connection before throwing so the SDK transport is not left
// with a half-read 401/403 body on streamable HTTP.
await response.body?.cancel().catch(() => {});
throw new AuthChallengeError(
challenge,
response.status,
`MCP auth challenge (${response.status})`,
);
};
}

View file

@ -12,6 +12,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { createFetchTracker } from "../fetchTracking.js";
import { createAuthChallengeInterceptFetch } from "./authChallengeFetch.js";
/**
* Build the wire `headers` record from `settings.headers`, dropping rows with
@ -46,9 +47,13 @@ export function createTransportNode(
onFetchResponseBody,
authProvider,
settings,
interceptAuthChallenges = false,
} = options;
const baseFetch = optionsFetchFn ?? globalThis.fetch;
const fetchWithOptionalAuthIntercept = interceptAuthChallenges
? createAuthChallengeInterceptFetch(baseFetch)
: baseFetch;
if (serverType === "stdio") {
const stdioConfig = config as StdioServerConfig;
@ -79,7 +84,8 @@ export function createTransportNode(
const url = new URL(sseConfig.url);
const sseFetch =
(sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch;
(sseConfig.eventSourceInit?.fetch as typeof fetch) ||
fetchWithOptionalAuthIntercept;
const trackedFetch = onFetchRequest
? createFetchTracker(sseFetch, {
trackRequest: onFetchRequest,
@ -101,11 +107,11 @@ export function createTransportNode(
};
const postFetch = onFetchRequest
? createFetchTracker(baseFetch, {
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: baseFetch;
: fetchWithOptionalAuthIntercept;
const transport = new SSEClientTransport(url, {
authProvider,
@ -128,11 +134,11 @@ export function createTransportNode(
};
const transportFetch = onFetchRequest
? createFetchTracker(baseFetch, {
? createFetchTracker(fetchWithOptionalAuthIntercept, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: baseFetch;
: fetchWithOptionalAuthIntercept;
const transport = new StreamableHTTPClientTransport(url, {
authProvider,

View file

@ -8,8 +8,8 @@ import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { mcpAuth } from "../auth/mcpAuth.js";
import { parseOAuthState } from "../auth/utils.js";
import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js";
import type { ClientConfig } from "../client/types.js";
@ -24,12 +24,27 @@ import {
import {
buildOAuthConnectionState,
hasPersistedOAuthServerState,
isAccessTokenUsable,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "../auth/connection-state.js";
import { ensureCimdClientRegistration } from "../auth/cimd.js";
import type { OAuthConnectionState } from "../auth/types.js";
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
import type {
AuthChallenge,
AuthChallengeOutcome,
HandleAuthChallengeOptions,
} from "../auth/challenge.js";
import {
parseScopeString,
unionAuthorizationScopes,
} from "../auth/challenge.js";
import {
computeScopeUnion,
isStrictScopeSuperset,
} from "../auth/scopes.js";
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
import type {
InspectorClientEnvironment,
InspectorClientOptions,
@ -60,10 +75,14 @@ export class OAuthManager {
private params: OAuthManagerParams;
private oauthConfig: OAuthManagerConfig;
private oauthFlowState: OAuthFlowState | null = null;
/** SEP-2350 union scope pending until interactive step-up completes. */
private pendingAuthorizationScope: string | undefined;
private authChallengeMutex: Promise<void> = Promise.resolve();
constructor(params: OAuthManagerParams) {
this.params = params;
this.oauthConfig = { ...params.initialConfig };
this.pendingAuthorizationScope = undefined;
}
setOAuthConfig(config: {
@ -104,7 +123,8 @@ export class OAuthManager {
provider.setEventTarget(this.params.getEventTarget());
if (this.oauthConfig.scope) {
const storedScope = this.oauthConfig.storage.getScope(serverUrl);
if (storedScope === undefined && this.oauthConfig.scope) {
await provider.saveScope(this.oauthConfig.scope);
}
@ -146,7 +166,11 @@ export class OAuthManager {
idp,
resourceClientId: this.oauthConfig.clientId,
resourceClientSecret: this.oauthConfig.clientSecret,
scope: this.oauthConfig.scope,
scope:
computeScopeUnion(
this.oauthConfig.scope,
this.oauthConfig.storage.getScope(this.getServerUrl()),
) || this.oauthConfig.scope,
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(),
storage: this.oauthConfig.storage,
fetchFn: this.params.effectiveAuthFetch,
@ -181,12 +205,8 @@ export class OAuthManager {
}
}
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationUrl,
};
this.requireNavigation().navigateToAuthorization(authorizationUrl);
await this.recordAuthorizationCodeFlowState(authorizationUrl);
return authorizationUrl;
}
@ -206,7 +226,7 @@ export class OAuthManager {
fetchFn: this.params.effectiveAuthFetch,
});
const result = await auth(provider, {
const result = await mcpAuth(provider, {
serverUrl,
scope: provider.scope,
fetchFn: this.params.effectiveAuthFetch,
@ -241,14 +261,28 @@ export class OAuthManager {
return capturedUrl;
}
async completeOAuthFlow(authorizationCode: string): Promise<void> {
async completeOAuthFlow(
authorizationCode: string,
iss?: string,
): Promise<void> {
try {
if (this.isEnterpriseManaged()) {
const config = this.getEmaFlowConfig();
const scopeForMint =
this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope;
const config = scopeForMint
? { ...this.getEmaFlowConfig(), scope: scopeForMint }
: this.getEmaFlowConfig();
const tokens = await completeEmaIdpAuthorizationAndMint(
config,
authorizationCode,
);
if (this.pendingAuthorizationScope) {
await this.oauthConfig.storage!.saveScope(
this.getServerUrl(),
this.pendingAuthorizationScope,
);
}
this.pendingAuthorizationScope = undefined;
const completedAt = Date.now();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
@ -263,9 +297,10 @@ export class OAuthManager {
const provider = await this.createOAuthProvider();
const serverUrl = this.getServerUrl();
const result = await auth(provider, {
const result = await mcpAuth(provider, {
serverUrl,
authorizationCode,
iss,
fetchFn: this.params.effectiveAuthFetch,
});
@ -280,6 +315,13 @@ export class OAuthManager {
throw new Error("Failed to retrieve tokens after authorization");
}
const scopeToPersist =
this.pendingAuthorizationScope ?? tokens.scope;
if (scopeToPersist) {
await provider.saveScope(scopeToPersist);
}
this.pendingAuthorizationScope = undefined;
const clientInfo = await provider.clientInformation();
const completedAt = Date.now();
this.oauthFlowState = this.oauthFlowState
@ -300,6 +342,7 @@ export class OAuthManager {
this.params.dispatchOAuthComplete({ tokens });
} catch (error) {
this.pendingAuthorizationScope = undefined;
this.params.dispatchOAuthError({
error: error instanceof Error ? error : new Error(String(error)),
});
@ -329,6 +372,7 @@ export class OAuthManager {
this.oauthConfig.storage.clear(serverUrl);
this.oauthFlowState = null;
this.pendingAuthorizationScope = undefined;
}
async isOAuthAuthorized(): Promise<boolean> {
@ -382,6 +426,387 @@ export class OAuthManager {
return tokens !== undefined;
}
/**
* Re-read persisted OAuth state and determine whether `challenge` is already
* satisfied without an authorization-server round-trip.
*
* Returns `true` for `insufficient_scope` when stored + token scope cover the
* SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`,
* returns `true` when a usable access token is already in storage.
*/
async checkAuthChallengeSatisfied(
challenge: AuthChallenge,
): Promise<boolean> {
const storage = this.oauthConfig.storage;
if (!storage) {
return false;
}
const serverUrl = this.getServerUrl();
const tokens = await storage.getTokens(serverUrl);
if (!tokens?.access_token) {
return false;
}
if (challenge.reason !== "insufficient_scope") {
return (
challenge.reason === "token_expired" ||
challenge.reason === "unauthorized" ||
challenge.reason === "invalid_token"
) && isAccessTokenUsable(tokens);
}
const enriched = await this.enrichChallengeWithAuthorizationScopes(
challenge,
tokens.scope,
);
const scopeForAuth =
enriched.authorizationScopes?.join(" ") ??
enriched.requiredScopes?.join(" ");
if (!scopeForAuth?.trim()) {
return false;
}
const effectiveScope = computeScopeUnion(
storage.getScope(serverUrl),
tokens.scope,
);
return !isStrictScopeSuperset(scopeForAuth, effectiveScope);
}
/**
* Satisfy a mid-session auth challenge when possible (silent refresh/re-mint or
* interactive redirect).
*
* Only `insufficient_scope` short-circuits on {@link checkAuthChallengeSatisfied}
* here — `token_expired` / `unauthorized` still attempt silent refresh even when
* storage holds a locally-valid token (the resource server may have invalidated it).
* Callers use {@link checkAuthChallengeSatisfied} directly before visible OAuth.
*
* Recovery is serialized per server so parallel challenges cannot race on
* `pendingAuthorizationScope` or OAuth provider state.
*/
async handleAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome> {
if (
challenge.reason === "insufficient_scope" &&
(await this.checkAuthChallengeSatisfied(challenge))
) {
return { kind: "satisfied" };
}
const prior = this.authChallengeMutex;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
this.authChallengeMutex = gate;
await prior;
try {
if (
challenge.reason === "insufficient_scope" &&
(await this.checkAuthChallengeSatisfied(challenge))
) {
return { kind: "satisfied" };
}
return await this.runHandleAuthChallenge(challenge, options);
} finally {
release();
}
}
private async runHandleAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome> {
if (this.isEnterpriseManaged()) {
return this.handleEnterpriseManagedAuthChallenge(challenge, options);
}
return this.handleStandardAuthChallenge(challenge);
}
private async enrichChallengeWithAuthorizationScopes(
challenge: AuthChallenge,
grantedTokenScope?: string,
): Promise<AuthChallenge> {
if (challenge.reason !== "insufficient_scope") {
return challenge;
}
const storage = this.oauthConfig.storage;
const serverUrl = this.getServerUrl();
if (grantedTokenScope === undefined && storage) {
const tokens = await storage.getTokens(serverUrl);
grantedTokenScope = tokens?.scope;
}
const previousScope = computeScopeUnion(
storage?.getScope(serverUrl),
grantedTokenScope,
);
const requiredFromChallenge = challenge.requiredScopes?.filter(Boolean) ?? [];
const grantedSet = new Set(parseScopeString(previousScope));
const missingRequired = requiredFromChallenge.filter(
(scope) => !grantedSet.has(scope),
);
const requiredScopes =
missingRequired.length > 0 ? missingRequired : requiredFromChallenge;
const authorizationScopes = unionAuthorizationScopes(
previousScope,
requiredFromChallenge,
);
return {
...challenge,
requiredScopes,
authorizationScopes,
};
}
private resolveEmaScopeForChallenge(challenge: AuthChallenge): string | undefined {
if (challenge.reason === "insufficient_scope") {
const fromChallenge = challenge.requiredScopes?.join(" ").trim();
if (fromChallenge) {
return fromChallenge;
}
}
return this.oauthConfig.scope?.trim() || undefined;
}
private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig {
const base = this.getEmaFlowConfig();
const enriched = challenge.authorizationScopes;
if (enriched && enriched.length > 0) {
return { ...base, scope: enriched.join(" ") };
}
const scope = this.resolveEmaScopeForChallenge(challenge);
return scope ? { ...base, scope } : base;
}
private async handleEnterpriseManagedAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome> {
const enriched = await this.enrichChallengeWithAuthorizationScopes(challenge);
if (enriched.reason === "insufficient_scope" && !options?.confirmedStepUp) {
return { kind: "step_up_confirm", challenge: enriched };
}
const config = this.emaFlowConfigForChallenge(enriched);
if (enriched.reason === "insufficient_scope") {
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
if (enriched.authorizationScopes?.length) {
await this.oauthConfig.storage!.saveScope(
this.getServerUrl(),
enriched.authorizationScopes.join(" "),
);
}
if (await this.checkAuthChallengeSatisfied(enriched)) {
return { kind: "satisfied" };
}
}
if (silent.status === "mint_failed") {
return { kind: "failed", error: silent.error };
}
} else {
const tokens = await refreshEmaResourceTokens(config);
if (tokens) {
return { kind: "satisfied" };
}
}
try {
const authorizationUrl = await startEmaIdpAuthorization(config);
if (
enriched.reason === "insufficient_scope" &&
enriched.authorizationScopes?.length
) {
this.pendingAuthorizationScope =
enriched.authorizationScopes.join(" ");
}
return { kind: "interactive", authorizationUrl, challenge: enriched };
} catch (error) {
return {
kind: "failed",
error: error instanceof Error ? error : new Error(String(error)),
};
}
}
private async handleStandardAuthChallenge(
challenge: AuthChallenge,
): Promise<AuthChallengeOutcome> {
const provider = await this.createOAuthProvider();
const serverUrl = this.getServerUrl();
const tokens = await provider.tokens();
const enriched = await this.enrichChallengeWithAuthorizationScopes(
challenge,
tokens?.scope,
);
provider.clearCapturedAuthUrl();
await ensureCimdClientRegistration({
serverUrl,
provider,
fetchFn: this.params.effectiveAuthFetch,
});
const scopeForAuth =
enriched.reason === "insufficient_scope"
? enriched.authorizationScopes?.join(" ")
: this.oauthConfig.scope?.trim() ||
(enriched.requiredScopes?.length
? enriched.requiredScopes.join(" ")
: undefined);
provider.setSuppressAuthorizationNavigation(true);
let result: Awaited<ReturnType<typeof mcpAuth>>;
try {
result = await mcpAuth(provider, {
serverUrl,
scope: scopeForAuth,
fetchFn: this.params.effectiveAuthFetch,
...(enriched.reason === "insufficient_scope" && {
forceReauthorization: isStrictScopeSuperset(
scopeForAuth,
tokens?.scope,
),
}),
});
} finally {
provider.setSuppressAuthorizationNavigation(false);
}
if (result === "AUTHORIZED") {
if (enriched.reason === "insufficient_scope") {
if (await this.checkAuthChallengeSatisfied(enriched)) {
if (scopeForAuth) {
await provider.saveScope(scopeForAuth);
}
return { kind: "satisfied" };
}
const forced = await this.tryForceReauthorizationForStepUp(
provider,
serverUrl,
scopeForAuth,
enriched,
);
if (forced) {
return forced;
}
return {
kind: "failed",
error: new Error(stepUpInsufficientScopeMessage(enriched)),
};
} else {
return { kind: "satisfied" };
}
}
const capturedUrl = provider.getCapturedAuthUrl();
if (!capturedUrl) {
return {
kind: "failed",
error: new Error("Failed to capture authorization URL"),
};
}
if (enriched.reason === "insufficient_scope" && scopeForAuth) {
this.pendingAuthorizationScope = scopeForAuth;
}
const clientInfo = await provider.clientInformation();
await this.recordAuthorizationCodeFlowState(capturedUrl, clientInfo);
return {
kind: "interactive",
authorizationUrl: capturedUrl,
challenge: enriched,
};
}
/** Start interactive OAuth after handleAuthChallenge returns `interactive`. */
async beginInteractiveAuthorization(authorizationUrl: URL): Promise<void> {
const stateParam = authorizationUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
this.requireNavigation().navigateToAuthorization(authorizationUrl);
const provider = await this.createOAuthProvider();
const clientInfo = await provider.clientInformation();
await this.recordAuthorizationCodeFlowState(authorizationUrl, clientInfo);
this.params.dispatchOAuthAuthorizationRequired({ url: authorizationUrl });
}
private requireNavigation(): NonNullable<OAuthManagerConfig["navigation"]> {
const navigation = this.oauthConfig.navigation;
if (!navigation) {
throw new Error("OAuth navigation is required.");
}
return navigation;
}
private async recordAuthorizationCodeFlowState(
authorizationUrl: URL,
oauthClientInfo?: OAuthClientInformation | null,
): Promise<void> {
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "authorization_code",
authorizationUrl,
oauthClientInfo: oauthClientInfo ?? null,
};
}
/**
* Silent refresh returned AUTHORIZED but token scope still lacks the step-up
* union — force a fresh authorization redirect.
*/
private async tryForceReauthorizationForStepUp(
provider: BaseOAuthClientProvider,
serverUrl: string,
scopeForAuth: string | undefined,
enriched: AuthChallenge,
): Promise<AuthChallengeOutcome | null> {
provider.clearCapturedAuthUrl();
provider.setSuppressAuthorizationNavigation(true);
let result: Awaited<ReturnType<typeof mcpAuth>>;
try {
result = await mcpAuth(provider, {
serverUrl,
scope: scopeForAuth,
fetchFn: this.params.effectiveAuthFetch,
forceReauthorization: true,
});
} finally {
provider.setSuppressAuthorizationNavigation(false);
}
if (result !== "AUTHORIZED") {
return null;
}
if (await this.checkAuthChallengeSatisfied(enriched)) {
if (scopeForAuth) {
await provider.saveScope(scopeForAuth);
}
return { kind: "satisfied" };
}
return null;
}
/**
* Create an OAuth provider for transport auth (connect()).
* Used only when isHttpOAuthConfig() is true.

View file

@ -6,6 +6,7 @@
export {
RemoteClientTransport,
type RemoteTransportOptions,
type AuthRecoveryHandlers,
} from "./remoteClientTransport.js";
export {
createRemoteTransport,
@ -26,6 +27,7 @@ export {
export type {
RemoteConnectRequest,
RemoteConnectResponse,
RemoteSendResponse,
RemoteEvent,
} from "./types.js";
export { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV } from "./constants.js";

View file

@ -6,12 +6,21 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import type { FetchRequestEntryBase } from "../../types.js";
import type { RemoteEvent } from "../types.js";
import type { AuthChallenge } from "../../../auth/challenge.js";
import { AuthChallengeError } from "../../../auth/challenge.js";
import type { RemoteAuthProviderHandle } from "./tokenAuthProvider.js";
import type { RemoteAuthState } from "../types.js";
export interface SessionEvent {
type: RemoteEvent["type"];
data: unknown;
}
type RequestWait = {
resolve: () => void;
reject: (error: Error) => void;
};
export class RemoteSession {
public readonly sessionId: string;
public transport!: Transport;
@ -19,11 +28,31 @@ export class RemoteSession {
private eventConsumer: ((event: SessionEvent) => void) | null = null;
private transportDead: boolean = false;
private transportError: string | null = null;
private activeSendCount = 0;
/**
* Suppress duplicate ambient SSE auth echoes after HTTP command-scoped delivery.
* Stale transport `onerror` may arrive after the send completes.
*/
private authHttpEchoSuppressUntilMs = 0;
static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000;
private readonly requestWaits = new Map<string | number, RequestWait>();
private authProviderHandle: RemoteAuthProviderHandle | null = null;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
setAuthProviderHandle(handle: RemoteAuthProviderHandle | null): void {
this.authProviderHandle = handle;
}
setAuthState(authState: RemoteAuthState): void {
if (!this.authProviderHandle) {
throw new Error("Session has no OAuth auth provider");
}
this.authProviderHandle.setAuthState(authState);
}
setTransport(transport: Transport): void {
this.transport = transport;
}
@ -72,6 +101,119 @@ export class RemoteSession {
return this.eventConsumer !== null;
}
beginSend(): void {
this.activeSendCount++;
}
endSend(): void {
if (this.activeSendCount > 0) {
this.activeSendCount--;
}
}
/** Command-scoped auth was returned on HTTP; suppress async onerror echo on SSE. */
noteAuthChallengeDeliveredViaHttp(): void {
this.extendAuthHttpEchoSuppression();
}
private extendAuthHttpEchoSuppression(): void {
this.authHttpEchoSuppressUntilMs =
Date.now() + RemoteSession.AUTH_HTTP_ECHO_SUPPRESS_MS;
}
private shouldSuppressAuthEcho(): boolean {
return Date.now() < this.authHttpEchoSuppressUntilMs;
}
hasActiveSend(): boolean {
return this.activeSendCount > 0;
}
pushAuthChallenge(challenge: AuthChallenge): void {
this.pushEvent({ type: "auth_challenge", data: challenge });
}
/**
* Streamable HTTP `transport.send()` can return before the JSON-RPC response
* arrives. The remote send handler awaits this so `/api/mcp/send` returns
* `auth_challenge` or `ok: true` only after the MCP round-trip (or auth error).
*/
waitForRequestResponse(
requestId: string | number,
timeoutMs = 60_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const timer =
timeoutMs > 0
? setTimeout(() => {
this.requestWaits.delete(requestId);
reject(
new Error(
`MCP request ${String(requestId)} timed out after ${timeoutMs}ms`,
),
);
}, timeoutMs)
: undefined;
this.requestWaits.set(requestId, {
resolve: () => {
if (timer !== undefined) {
clearTimeout(timer);
}
resolve();
},
reject: (error) => {
if (timer !== undefined) {
clearTimeout(timer);
}
reject(error);
},
});
});
}
cancelRequestWait(requestId: string | number): void {
this.requestWaits.delete(requestId);
}
rejectActiveRequestWaits(error: Error): void {
for (const wait of this.requestWaits.values()) {
wait.reject(error);
}
this.requestWaits.clear();
}
/**
* Auth errors from the MCP transport.
* - During an active send: command path owns delivery (HTTP); never SSE.
* - After HTTP already returned auth_challenge: swallow async onerror echo.
* - Otherwise: ambient SSE (idle session).
*/
handleTransportAuthError(error: unknown): error is AuthChallengeError {
if (!(error instanceof AuthChallengeError)) {
return false;
}
this.rejectActiveRequestWaits(error);
if (this.hasActiveSend()) {
this.extendAuthHttpEchoSuppression();
return true;
}
if (this.shouldSuppressAuthEcho()) {
return true;
}
this.pushAuthChallenge(error.authChallenge);
return true;
}
private settleRequestWait(requestId: string | number): void {
const wait = this.requestWaits.get(requestId);
if (!wait) {
return;
}
this.requestWaits.delete(requestId);
wait.resolve();
}
pushEvent(event: SessionEvent): void {
if (this.eventConsumer) {
this.eventConsumer(event);
@ -81,6 +223,14 @@ export class RemoteSession {
}
onMessage(message: JSONRPCMessage): void {
if (
"id" in message &&
message.id !== null &&
message.id !== undefined &&
("result" in message || "error" in message)
) {
this.settleRequestWait(message.id);
}
this.pushEvent({ type: "message", data: message });
}

View file

@ -26,7 +26,13 @@ import type { Context, Env, Next } from "hono";
import { streamSSE } from "hono/streaming";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import { createTransportNode } from "../../node/transport.js";
import type { RemoteConnectRequest, RemoteSendRequest } from "../types.js";
import type {
RemoteConnectRequest,
RemoteSendRequest,
RemoteSetAuthStateRequest,
} from "../types.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { AuthChallengeError } from "../../../auth/challenge.js";
import {
DEFAULT_MAX_FETCH_REQUESTS,
DEFAULT_TASK_TTL_MS,
@ -52,7 +58,7 @@ import {
} from "../../serverList.js";
import { resolveImportSource } from "../../import/resolveSource.js";
import { RemoteSession } from "./remote-session.js";
import { createTokenAuthProvider } from "./tokenAuthProvider.js";
import { createRemoteAuthProvider } from "./tokenAuthProvider.js";
import { API_SERVER_ENV_VARS } from "../constants.js";
import {
KeychainUnavailableError,
@ -321,6 +327,20 @@ function forwardLogEvent(
}
}
function requestIdForSendWait(
message: JSONRPCMessage,
): string | number | undefined {
if (
"method" in message &&
"id" in message &&
message.id !== null &&
message.id !== undefined
) {
return message.id;
}
return undefined;
}
export function createRemoteApp(
options: RemoteServerOptions,
): CreateRemoteAppResult {
@ -525,9 +545,12 @@ export function createRemoteApp(
const session = new RemoteSession(sessionId);
let transport: Awaited<ReturnType<typeof createTransportNode>>["transport"];
let authHandle: ReturnType<typeof createRemoteAuthProvider>;
try {
// Create authProvider from tokens if provided
const authProvider = createTokenAuthProvider(body.oauthTokens);
const initialAuthState =
body.authState ??
(body.oauthTokens ? { oauthTokens: body.oauthTokens } : undefined);
authHandle = createRemoteAuthProvider(initialAuthState);
const result = createTransportNode(config, {
pipeStderr: true,
@ -535,8 +558,12 @@ export function createRemoteApp(
onFetchRequest: (entry) => session.onFetchRequest(entry),
onFetchResponseBody: (id, body) =>
session.onFetchResponseBody(id, body),
authProvider,
authProvider: authHandle?.provider,
settings: body.settings,
// Always intercept 401/403 on the node MCP transport. Without this, the
// SDK's built-in auth() retry on a frozen token snapshot can hang or
// succeed locally while /api/mcp/send still awaits a JSON-RPC response.
interceptAuthChallenges: true,
});
transport = result.transport;
} catch (err) {
@ -544,6 +571,7 @@ export function createRemoteApp(
return c.json({ error: `Failed to create transport: ${msg}` }, 500);
}
session.setAuthProviderHandle(authHandle ?? null);
session.setTransport(transport);
transport.onmessage = (msg) => session.onMessage(msg);
@ -558,6 +586,10 @@ export function createRemoteApp(
// Set up error handlers BEFORE calling start() so we catch failures during start
transport.onerror = (err) => {
if (session.handleTransportAuthError(err)) {
originalOnerror?.(err);
return;
}
transportFailed = true;
transportError = err instanceof Error ? err.message : String(err);
originalOnerror?.(err);
@ -609,6 +641,19 @@ export function createRemoteApp(
}
} catch (err) {
// transport.start() threw - this is the expected failure path
if (err instanceof AuthChallengeError) {
try {
await transport.close();
} catch {
// Best-effort cleanup when connect fails with auth challenge.
}
session.noteAuthChallengeDeliveredViaHttp();
return c.json({
ok: false,
kind: "auth_challenge",
authChallenge: err.authChallenge,
});
}
const msg = err instanceof Error ? err.message : String(err);
// Preserve 401 only when the transport/SDK reports it (no message guessing)
const status =
@ -648,22 +693,41 @@ export function createRemoteApp(
// Check if transport is dead - return error immediately (matches local behavior)
if (session.isTransportDead()) {
const errorMsg = session.getTransportError() || "Transport closed";
return c.json({ error: errorMsg }, 500);
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
}
session.beginSend();
const requestId = requestIdForSendWait(message);
let responseWait: Promise<void> | undefined;
if (requestId !== undefined) {
responseWait = session.waitForRequestResponse(requestId);
// Auth errors may reject the wait via onerror while send() also throws.
void responseWait.catch(() => {});
}
try {
await session.transport.send(message, {
relatedRequestId: relatedRequestId as string | number | undefined,
});
if (responseWait) {
await responseWait;
}
return c.json({ ok: true });
} catch (err) {
if (requestId !== undefined) {
session.cancelRequestWait(requestId);
}
if (err instanceof AuthChallengeError) {
session.noteAuthChallengeDeliveredViaHttp();
return c.json({
ok: false,
kind: "auth_challenge",
authChallenge: err.authChallenge,
});
}
const msg = err instanceof Error ? err.message : String(err);
// Preserve 401 only when the transport/SDK reports it (no message guessing)
const status =
(err as { code?: number; status?: number }).code ??
(err as { code?: number; status?: number }).status;
const is401 = status === 401;
return c.json({ error: msg }, is401 ? 401 : 500);
return c.json({ ok: false, kind: "transport_error", error: msg });
} finally {
session.endSend();
}
});
@ -746,6 +810,39 @@ export function createRemoteApp(
return c.json({ ok: true });
});
app.post("/api/mcp/auth-state", async (c) => {
let body: RemoteSetAuthStateRequest;
try {
body = (await c.req.json()) as RemoteSetAuthStateRequest;
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
const { sessionId, authState } = body;
if (!sessionId || !authState) {
return c.json({ error: "Missing sessionId or authState" }, 400);
}
const session = sessions.get(sessionId);
if (!session) {
return c.json({ error: "Session not found" }, 404);
}
if (session.isTransportDead()) {
const errorMsg = session.getTransportError() || "Transport closed";
return c.json({ ok: false, kind: "transport_error", error: errorMsg });
}
try {
session.setAuthState(authState);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: msg }, 400);
}
return c.json({ ok: true });
});
app.post("/api/fetch", async (c) => {
let body: {
url: string;

View file

@ -1,29 +1,48 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import type {
OAuthClientInformation,
OAuthClientMetadata,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { RemoteConnectRequest } from "../types.js";
import type { RemoteAuthState } from "../types.js";
const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = {
redirect_uris: [],
scope: "",
};
/**
* Simple OAuth client provider that just returns tokens.
* Used by the remote server to inject Bearer tokens into transport requests.
*
* The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider
* surface so that path fails with a clear error instead of throwing on
* undefined `clientMetadata.scope`.
*/
export function createTokenAuthProvider(
tokens: RemoteConnectRequest["oauthTokens"],
): OAuthClientProvider | undefined {
if (!tokens) return undefined;
export interface RemoteAuthProviderHandle {
provider: OAuthClientProvider;
setAuthState: (authState: RemoteAuthState) => void;
getAuthState: () => RemoteAuthState;
}
function cloneAuthState(state: RemoteAuthState): RemoteAuthState {
return {
...(state.oauthTokens && {
oauthTokens: { ...state.oauthTokens },
}),
...(state.oauthClient && {
oauthClient: { ...state.oauthClient },
}),
};
}
/**
* Mutable OAuth provider for the remote backend MCP transport.
* Browser (or CLI) owns interactive OAuth; this injects Bearer tokens and can
* be hot-updated via {@link RemoteAuthProviderHandle.setAuthState}.
*/
export function createRemoteAuthProvider(
initialAuthState?: RemoteAuthState,
): RemoteAuthProviderHandle | undefined {
if (!initialAuthState?.oauthTokens && !initialAuthState?.oauthClient) {
return undefined;
}
let authState = cloneAuthState(initialAuthState);
const provider = {
get clientMetadata(): OAuthClientMetadata {
return REMOTE_OAUTH_STUB_METADATA;
},
@ -31,13 +50,31 @@ export function createTokenAuthProvider(
return "";
},
async tokens(): Promise<OAuthTokens | undefined> {
return tokens as OAuthTokens;
return authState.oauthTokens as OAuthTokens | undefined;
},
async clientInformation() {
return undefined;
async clientInformation(): Promise<OAuthClientInformation | undefined> {
if (!authState.oauthClient?.client_id) {
return undefined;
}
return {
client_id: authState.oauthClient.client_id,
...(authState.oauthClient.client_secret && {
client_secret: authState.oauthClient.client_secret,
}),
};
},
async saveTokens() {
// No-op
async saveTokens(tokens: OAuthTokens) {
authState = {
...authState,
oauthTokens: {
access_token: tokens.access_token,
token_type: tokens.token_type,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
scope: tokens.scope,
id_token: tokens.id_token,
},
};
},
codeVerifier() {
return undefined;
@ -46,7 +83,7 @@ export function createTokenAuthProvider(
// No-op
},
clear() {
// No-op
authState = {};
},
async redirectToAuthorization() {
throw new Error(
@ -57,4 +94,14 @@ export function createTokenAuthProvider(
return "";
},
} as unknown as OAuthClientProvider;
return {
provider,
setAuthState(next: RemoteAuthState) {
authState = cloneAuthState(next);
},
getAuthState() {
return cloneAuthState(authState);
},
};
}

View file

@ -13,11 +13,33 @@ import type {
} from "@modelcontextprotocol/sdk/types.js";
import type { InspectorServerSettings, StderrLogEntry } from "../types.js";
import type { FetchRequestEntryBase } from "../types.js";
import type {
AuthChallenge,
AuthChallengeOutcome,
HandleAuthChallengeOptions,
} from "../../auth/challenge.js";
import {
AuthChallengeError,
AuthRecoveryRequiredError,
EMA_STEP_UP_PENDING_URL,
} from "../../auth/challenge.js";
import type {
RemoteConnectRequest,
RemoteConnectResponse,
RemoteAuthState,
RemoteEvent,
RemoteSendResponse,
} from "./types.js";
import { oauthTokensToRemoteAuthState } from "./types.js";
export interface AuthRecoveryHandlers {
handleAuthChallenge(
challenge: AuthChallenge,
options?: HandleAuthChallengeOptions,
): Promise<AuthChallengeOutcome>;
/** Push recovered auth state to the remote backend (same session). */
pushAuthState?: (authState?: RemoteAuthState) => Promise<void>;
}
export interface RemoteTransportOptions {
/** Base URL of the remote server (e.g. http://localhost:3000) */
@ -47,6 +69,84 @@ export interface RemoteTransportOptions {
* custom headers (SSE / streamable-http).
*/
settings?: InspectorServerSettings;
/** Mid-session auth recovery (handle challenge on command-scoped send). */
authRecovery?: AuthRecoveryHandlers;
/** Ambient auth challenges delivered via SSE (no active send). */
onAuthChallenge?: (challenge: AuthChallenge) => void;
/** Max wait for a JSON-RPC response on SSE after HTTP `{ ok: true }`. Default 60s. */
sseResponseTimeoutMs?: number;
}
const DEFAULT_SSE_RESPONSE_TIMEOUT_MS = 60_000;
type SseResponseWait = {
resolve: () => void;
reject: (error: Error) => void;
};
function requestIdForMessage(
message: JSONRPCMessage,
): string | number | undefined {
if (
"method" in message &&
"id" in message &&
message.id !== null &&
message.id !== undefined
) {
return message.id;
}
return undefined;
}
function isConnectAuthChallenge(
json: RemoteConnectResponse,
): json is Extract<RemoteConnectResponse, { ok: false; kind: "auth_challenge" }> {
return (
typeof json === "object" &&
json !== null &&
"ok" in json &&
json.ok === false &&
"kind" in json &&
json.kind === "auth_challenge"
);
}
function isConnectTransportError(
json: RemoteConnectResponse,
): json is Extract<RemoteConnectResponse, { ok: false; kind: "transport_error" }> {
return (
typeof json === "object" &&
json !== null &&
"ok" in json &&
json.ok === false &&
"kind" in json &&
json.kind === "transport_error"
);
}
function legacySessionId(json: RemoteConnectResponse): string | undefined {
if (
typeof json === "object" &&
json !== null &&
"sessionId" in json &&
typeof json.sessionId === "string" &&
!("ok" in json)
) {
return json.sessionId;
}
if (
typeof json === "object" &&
json !== null &&
"ok" in json &&
json.ok === true &&
"sessionId" in json
) {
return json.sessionId;
}
return undefined;
}
/**
@ -109,7 +209,10 @@ export class RemoteClientTransport implements Transport {
private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null =
null;
private eventStreamAbort: AbortController | null = null;
private eventStreamConsumeTask: Promise<void> | null = null;
private restartingEventStream = false;
private closed = false;
private readonly sseResponseWaits = new Map<string | number, SseResponseWait>();
private readonly options: RemoteTransportOptions;
private readonly config: import("../types.js").MCPServerConfig;
@ -124,6 +227,24 @@ export class RemoteClientTransport implements Transport {
return undefined;
}
/** Remote Hono session id (distinct from MCP protocol session). */
getRemoteBackendSessionId(): string | undefined {
return this._sessionId;
}
/**
* Reattach to an existing remote backend session after a full-page OAuth
* redirect. Opens the SSE event stream without POST /connect.
*/
async attachToSession(sessionId: string): Promise<void> {
if (this.closed) {
this.closed = false;
}
await this.stopEventStream();
this._sessionId = sessionId;
await this.openEventStream();
}
constructor(
options: RemoteTransportOptions,
config: import("../types.js").MCPServerConfig,
@ -132,6 +253,16 @@ export class RemoteClientTransport implements Transport {
this.config = config;
}
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
this.options.authRecovery = handlers;
}
setOnAuthChallenge(
handler: ((challenge: AuthChallenge) => void) | undefined,
): void {
this.options.onAuthChallenge = handler;
}
private get fetchFn(): typeof fetch {
return this.options.fetchFn ?? globalThis.fetch;
}
@ -150,28 +281,62 @@ export class RemoteClientTransport implements Transport {
return h;
}
private async recoverFromAuthChallenge(
challenge: AuthChallenge,
retry: () => Promise<void>,
): Promise<void> {
const recovery = this.options.authRecovery;
if (!recovery) {
throw new AuthChallengeError(
challenge,
challenge.raw?.httpStatus ?? 401,
);
}
const outcome = await recovery.handleAuthChallenge(challenge);
if (outcome.kind === "satisfied") {
await retry();
return;
}
if (outcome.kind === "step_up_confirm") {
throw new AuthRecoveryRequiredError(
EMA_STEP_UP_PENDING_URL,
outcome.challenge,
{ emaStepUpConfirm: true },
);
}
if (outcome.kind === "interactive") {
throw new AuthRecoveryRequiredError(
outcome.authorizationUrl,
outcome.challenge,
);
}
throw outcome.error;
}
async start(): Promise<void> {
if (this._sessionId && !this.closed) {
return;
}
return this.startWithRecovery(0);
}
private async startWithRecovery(retryCount: number): Promise<void> {
/* v8 ignore next -- the sessionId getter is hardcoded to return undefined (see its doc comment), so this guard is never taken; it exists to satisfy the Transport contract. */
if (this.sessionId) return;
if (this.closed) throw new Error("Transport is closed");
// Extract OAuth tokens from authProvider if available
let oauthTokens: RemoteConnectRequest["oauthTokens"] | undefined;
let authState: RemoteAuthState | undefined;
if (this.options.authProvider) {
const tokens = await this.options.authProvider.tokens();
if (tokens) {
oauthTokens = {
access_token: tokens.access_token,
token_type: tokens.token_type,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
};
authState = oauthTokensToRemoteAuthState(tokens);
}
}
const body: RemoteConnectRequest = {
config: this.config,
oauthTokens,
...(authState && { authState }),
...(this.options.settings && { settings: this.options.settings }),
};
@ -183,20 +348,40 @@ export class RemoteClientTransport implements Transport {
if (!res.ok) {
const text = await res.text();
// Preserve the status code in the error so callers can detect 401
const error = new Error(`Remote connect failed (${res.status}): ${text}`);
(error as { status?: number }).status = res.status;
throw error;
}
const json = (await res.json()) as RemoteConnectResponse;
this._sessionId = json.sessionId;
if (!this._sessionId) {
if (isConnectAuthChallenge(json)) {
if (retryCount >= 1) {
throw new AuthChallengeError(
json.authChallenge,
json.authChallenge.raw?.httpStatus ?? 401,
);
}
await this.recoverFromAuthChallenge(json.authChallenge, () =>
this.startWithRecovery(retryCount + 1),
);
return;
}
if (isConnectTransportError(json)) {
throw new Error(`Remote connect failed: ${json.error}`);
}
const sessionId = legacySessionId(json);
if (!sessionId) {
throw new Error("Remote did not return sessionId");
}
// Open SSE event stream
this._sessionId = sessionId;
await this.openEventStream();
}
private async openEventStream(): Promise<void> {
this.eventStreamAbort = new AbortController();
const eventRes = await this.fetchFn(
`${this.baseUrl}/api/mcp/events?sessionId=${encodeURIComponent(this._sessionId!)}`,
@ -221,7 +406,30 @@ export class RemoteClientTransport implements Transport {
}
this.eventStreamReader = bodyStream.getReader();
this.consumeEventStream();
this.eventStreamConsumeTask = this.consumeEventStream();
}
/** Stop the SSE consumer and release the reader before opening a new stream. */
private async stopEventStream(): Promise<void> {
this.restartingEventStream = true;
try {
this.eventStreamAbort?.abort();
const reader = this.eventStreamReader;
if (reader) {
try {
await reader.cancel();
} catch {
// Ignore cancel errors during close
}
}
this.eventStreamReader = null;
if (this.eventStreamConsumeTask) {
await this.eventStreamConsumeTask;
this.eventStreamConsumeTask = null;
}
} finally {
this.restartingEventStream = false;
}
}
private async consumeEventStream(): Promise<void> {
@ -236,7 +444,9 @@ export class RemoteClientTransport implements Transport {
const parsed = JSON.parse(data) as RemoteEvent;
if (parsed.type === "message") {
this.onmessage?.(parsed.data as JSONRPCMessage, undefined);
const msg = parsed.data as JSONRPCMessage;
this.settleSseResponseWait(msg);
this.onmessage?.(msg, undefined);
} else if (
parsed.type === "fetch_request" &&
this.options.onFetchRequest
@ -262,32 +472,32 @@ export class RemoteClientTransport implements Transport {
timestamp: new Date(parsed.data.timestamp),
message: parsed.data.message,
});
} else if (parsed.type === "auth_challenge") {
this.options.onAuthChallenge?.(parsed.data);
} else if (parsed.type === "transport_error") {
// Transport died - notify client and close (matches local behavior)
const error = new Error(parsed.data.error);
if (parsed.data.code !== undefined) {
(error as { code?: number | string }).code = parsed.data.code;
}
this.onerror?.(error);
// Also trigger onclose to match local transport behavior
if (!this.closed) {
this.closed = true;
this.onclose?.();
if (!this.restartingEventStream) {
this.onerror?.(error);
if (!this.closed) {
this.closed = true;
this.onclose?.();
}
}
}
} catch (err) {
// JSON parse error or other processing error - report but continue
this.onerror?.(err instanceof Error ? err : new Error(String(err)));
}
}
} catch (err) {
// Stream reading error (network issue, abort, etc.)
if (!this.closed && err instanceof Error && err.name !== "AbortError") {
this.onerror?.(err);
}
} finally {
this.eventStreamReader = null;
if (!this.closed) {
if (!this.closed && !this.restartingEventStream) {
this.closed = true;
this.onclose?.();
}
@ -298,6 +508,167 @@ export class RemoteClientTransport implements Transport {
message: JSONRPCMessage,
options?: TransportSendOptions,
): Promise<void> {
return this.postSend(message, options, 0);
}
/**
* Push auth state to the remote backend without tearing down the session.
* Used after mid-session OAuth recovery in the web client.
*/
async pushAuthState(authState?: RemoteAuthState): Promise<void> {
if (!this._sessionId) {
throw new Error("Transport not started");
}
if (this.closed) {
throw new Error("Transport is closed");
}
const state = authState ?? (await this.buildAuthStateFromProvider());
if (!state.oauthTokens && !state.oauthClient) {
throw new Error("No auth state to push");
}
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/auth-state`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ sessionId: this._sessionId, authState: state }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Remote auth-state update failed (${res.status}): ${text}`);
}
const json = (await res.json()) as { ok?: boolean; error?: string };
if (!json.ok) {
throw new Error(json.error ?? "Remote auth-state update failed");
}
}
private async buildAuthStateFromProvider(): Promise<RemoteAuthState> {
if (!this.options.authProvider) {
throw new Error("No auth provider configured");
}
const tokens = await this.options.authProvider.tokens();
if (!tokens) {
throw new Error("No OAuth tokens available");
}
return oauthTokensToRemoteAuthState(tokens);
}
private get sseResponseTimeoutMs(): number {
return this.options.sseResponseTimeoutMs ?? DEFAULT_SSE_RESPONSE_TIMEOUT_MS;
}
private settleSseResponseWait(message: JSONRPCMessage): void {
if (
!("id" in message) ||
message.id === null ||
message.id === undefined ||
(!("result" in message) && !("error" in message))
) {
return;
}
const wait = this.sseResponseWaits.get(message.id);
if (!wait) {
return;
}
this.sseResponseWaits.delete(message.id);
wait.resolve();
}
private cancelSseResponseWait(requestId: string | number): void {
this.sseResponseWaits.delete(requestId);
}
private cancelAllSseWaits(error: Error): void {
for (const wait of this.sseResponseWaits.values()) {
wait.reject(error);
}
this.sseResponseWaits.clear();
}
private waitForSseResponse(requestId: string | number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.sseResponseWaits.delete(requestId);
reject(
new Error(
`Timed out waiting for MCP response on SSE (${this.sseResponseTimeoutMs}ms)`,
),
);
}, this.sseResponseTimeoutMs);
this.sseResponseWaits.set(requestId, {
resolve: () => {
clearTimeout(timer);
resolve();
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
});
}
private async postSend(
message: JSONRPCMessage,
options: TransportSendOptions | undefined,
retryCount: number,
): Promise<void> {
const requestId = requestIdForMessage(message);
let sseWait: Promise<void> | undefined;
if (requestId !== undefined) {
sseWait = this.waitForSseResponse(requestId);
void sseWait.catch(() => {});
}
let json: RemoteSendResponse;
try {
json = await this.requestSend(message, options);
} catch (error) {
if (requestId !== undefined) {
this.cancelSseResponseWait(requestId);
}
throw error;
}
if (json.ok) {
if (sseWait) {
await sseWait;
}
return;
}
if (requestId !== undefined) {
this.cancelSseResponseWait(requestId);
}
if (json.kind === "auth_challenge") {
// Send-time recovery requires pushAuthState on the existing remote session.
// Connect-time recovery (startWithRecovery) retries with fresh authState in
// the connect body instead — see startWithRecovery().
if (retryCount >= 1 || !this.options.authRecovery?.pushAuthState) {
throw new AuthChallengeError(
json.authChallenge,
json.authChallenge.raw?.httpStatus ?? 401,
);
}
await this.recoverFromAuthChallenge(json.authChallenge, async () => {
await this.options.authRecovery!.pushAuthState!();
return this.postSend(message, options, retryCount + 1);
});
return;
}
throw new Error(json.error);
}
private async requestSend(
message: JSONRPCMessage,
options?: TransportSendOptions,
): Promise<RemoteSendResponse> {
if (!this._sessionId) {
throw new Error("Transport not started");
}
@ -325,14 +696,16 @@ export class RemoteClientTransport implements Transport {
(error as { status?: number }).status = res.status;
throw error;
}
return (await res.json()) as RemoteSendResponse;
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.eventStreamAbort?.abort();
this.eventStreamReader = null;
this.cancelAllSseWaits(new Error("Transport closed"));
await this.stopEventStream();
if (this._sessionId) {
try {

View file

@ -8,6 +8,50 @@ import type {
InspectorServerSettings,
} from "../types.js";
import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import type { AuthChallenge } from "../../auth/challenge.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
/** OAuth token set pushed to the remote backend for upstream MCP Bearer auth. */
export interface RemoteMcpOAuthTokens {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
id_token?: string;
}
/**
* Auth credentials for the upstream MCP transport on the remote backend.
* Extensible so server-side token refresh can be added without a new API shape.
*/
export interface RemoteAuthState {
/** Bearer tokens for HTTP MCP transports (streamable-http / SSE). */
oauthTokens?: RemoteMcpOAuthTokens;
/**
* OAuth client credentials for server-side refresh at the token endpoint.
* Optional today while the browser owns interactive OAuth and refresh.
*/
oauthClient?: {
client_id: string;
client_secret?: string;
};
}
export function oauthTokensToRemoteAuthState(
tokens: OAuthTokens | RemoteMcpOAuthTokens,
): RemoteAuthState {
return {
oauthTokens: {
access_token: tokens.access_token,
token_type: tokens.token_type,
expires_in: tokens.expires_in,
refresh_token: tokens.refresh_token,
scope: tokens.scope,
id_token: tokens.id_token,
},
};
}
export interface RemoteConnectRequest {
/** MCP server config (stdio, sse, or streamable-http) */
@ -18,19 +62,60 @@ export interface RemoteConnectRequest {
* from the legacy `config.headers` field (which has been removed).
*/
settings?: InspectorServerSettings;
/** Optional OAuth tokens for Bearer authentication (for HTTP transports) */
oauthTokens?: {
access_token: string;
token_type: string;
expires_in?: number;
refresh_token?: string;
};
/**
* Initial auth for upstream MCP HTTP transports.
* Prefer {@link authState}; {@link oauthTokens} is accepted for compatibility.
*/
authState?: RemoteAuthState;
/** @deprecated Prefer {@link authState}. */
oauthTokens?: RemoteMcpOAuthTokens;
}
export interface RemoteConnectResponse {
export interface RemoteSetAuthStateRequest {
sessionId: string;
authState: RemoteAuthState;
}
export interface RemoteSetAuthStateResponse {
ok: true;
}
export interface RemoteConnectResponseSuccess {
ok: true;
sessionId: string;
}
export interface RemoteConnectResponseAuthChallenge {
ok: false;
kind: "auth_challenge";
authChallenge: AuthChallenge;
}
export interface RemoteConnectResponseTransportError {
ok: false;
kind: "transport_error";
error: string;
}
export type RemoteConnectResponse =
| RemoteConnectResponseSuccess
| RemoteConnectResponseAuthChallenge
| RemoteConnectResponseTransportError
| { sessionId: string };
export type RemoteSendResponse =
| { ok: true }
| {
ok: false;
kind: "auth_challenge";
authChallenge: AuthChallenge;
}
| {
ok: false;
kind: "transport_error";
error: string;
};
export interface RemoteSendRequest {
message: JSONRPCMessage;
/** Optional, for associating response with request (e.g. streamable-http) */
@ -42,7 +127,8 @@ export type RemoteEventType =
| "fetch_request"
| "fetch_request_body_update"
| "stdio_log"
| "transport_error";
| "transport_error"
| "auth_challenge";
export interface RemoteEventMessage {
type: "message";
@ -72,9 +158,15 @@ export interface RemoteEventTransportError {
};
}
export interface RemoteEventAuthChallenge {
type: "auth_challenge";
data: AuthChallenge;
}
export type RemoteEvent =
| RemoteEventMessage
| RemoteEventFetchRequest
| RemoteEventFetchRequestBodyUpdate
| RemoteEventStdioLog
| RemoteEventTransportError;
| RemoteEventTransportError
| RemoteEventAuthChallenge;

View file

@ -545,6 +545,12 @@ export interface CreateTransportOptions {
* Stdio ignores this — headers are not applicable.
*/
settings?: InspectorServerSettings;
/**
* When true, wrap HTTP transport fetch with auth-challenge detection so 401/403
* become {@link AuthChallengeError} before the SDK calls `auth()` on a frozen provider.
*/
interceptAuthChallenges?: boolean;
}
export interface CreateTransportResult {
@ -746,6 +752,13 @@ export interface InspectorClientOptions {
*/
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
/**
* When true, direct transports (TUI/CLI) route MCP 401/403 through
* `handleAuthChallenge()` via fetch intercept instead of the SDK auth() path.
* Web remote clients should leave this false (default).
*/
directAuthRecovery?: boolean;
/**
* Optional session ID. If not provided, will be extracted from OAuth state
* when OAuth flow starts. Passed in saveSession event for FetchRequestLogState.