All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
3 changed files with 11437 additions and 1212 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue