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