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
3 changed files with 11437 additions and 1212 deletions

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);
},
};
}