(
+ null,
+ );
const hasAttemptedSendRef = useRef(false);
useEffect(() => {
@@ -16,7 +31,28 @@ export default function OAuthDebugCallback() {
return;
}
- // If successful and we have a code, send it to the parent window
+ const isInPopup = Boolean(window.opener && !window.opener.closed);
+ const isNamedOAuthPopup = window.name.startsWith("oauth_authorization_");
+
+ // Electron cold-start debug callbacks do not have a live opener session to
+ // hand results back to, so keep the callback visible instead of attempting
+ // popup behavior in the only app window.
+ if (window.isElectron && !isInPopup) {
+ return;
+ }
+
+ // System-browser debug callbacks need to bounce back into the Electron app.
+ // Browser popup flows may lose `window.opener` due to COOP, so preserve the
+ // fallback path for our named OAuth popup windows.
+ if (!window.isElectron && !isInPopup && !isNamedOAuthPopup) {
+ hasAttemptedSendRef.current = true;
+ const electronUrl = buildElectronDebugCallbackUrl();
+ setReturnToElectronUrl(electronUrl);
+ window.location.replace(electronUrl);
+ return;
+ }
+
+ // If successful and we have a code, send it to the parent window.
if (callbackParams.successful && callbackParams.code) {
hasAttemptedSendRef.current = true;
try {
@@ -29,9 +65,6 @@ export default function OAuthDebugCallback() {
state: stateParam,
};
- // Check if we're in a popup window
- const isInPopup = window.opener && !window.opener.closed;
-
// Method 1: Try window.opener (works most of the time)
if (isInPopup) {
window.opener.postMessage(message, window.location.origin);
@@ -97,6 +130,13 @@ export default function OAuthDebugCallback() {
Return to the OAuth Flow tab and paste the code to continue.
+ {window.isElectron && !window.opener && (
+
+ The debug session is no longer attached to a popup window.
+ Reopen the OAuth Flow tab in MCPJam Inspector and start the
+ flow again to exchange a fresh code.
+
+ )}
>
)}
>
@@ -111,8 +151,29 @@ export default function OAuthDebugCallback() {
{generateOAuthErrorDescription(callbackParams)}
+ {window.isElectron && !window.opener && (
+
+ The debug session is no longer active in a popup window. Return
+ to MCPJam Inspector and start a new flow to retry.
+
+ )}
>
)}
+ {!window.isElectron && returnToElectronUrl && (
+
+
+ MCPJam Desktop should open automatically. If you are back in the
+ browser, please close this page and continue in MCPJam Desktop.
+
+
+ If nothing happened,{" "}
+
+ click here
+
+ .
+
+
+ )}
);
diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthDesktopReturnNotice.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthDesktopReturnNotice.tsx
new file mode 100644
index 000000000..5e4eb4fcf
--- /dev/null
+++ b/mcpjam-inspector/client/src/components/oauth/OAuthDesktopReturnNotice.tsx
@@ -0,0 +1,26 @@
+interface OAuthDesktopReturnNoticeProps {
+ returnToElectronUrl: string;
+}
+
+export default function OAuthDesktopReturnNotice({
+ returnToElectronUrl,
+}: OAuthDesktopReturnNoticeProps) {
+ return (
+
+
+
Continue in MCPJam Desktop
+
+ MCPJam Desktop should open automatically. If you are back in the
+ browser, please close this page and continue in MCPJam Desktop.
+
+
+ If nothing happened,{" "}
+
+ click here
+
+ .
+
+
+
+ );
+}
diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthFlowProgressSimple.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthFlowProgressSimple.tsx
index 4544ddcb7..438865ac1 100644
--- a/mcpjam-inspector/client/src/components/oauth/OAuthFlowProgressSimple.tsx
+++ b/mcpjam-inspector/client/src/components/oauth/OAuthFlowProgressSimple.tsx
@@ -1,6 +1,6 @@
import { OAuthFlowState, OAuthStep } from "@/lib/types/oauth-flow-types";
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
-import { useEffect, useState, useMemo } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
import { Button } from "@/components/ui/button";
import { DebugMCPOAuthClientProvider } from "@/lib/oauth/debug-oauth-provider";
@@ -79,6 +79,7 @@ export const OAuthFlowProgressSimple = ({
const [clientInfo, setClientInfo] = useState(
null,
);
+ const processedElectronCodeRef = useRef(null);
// Track if authorization modal is open
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
@@ -111,6 +112,63 @@ export const OAuthFlowProgressSimple = ({
currentStepIdx,
]);
+ useEffect(() => {
+ const handleElectronOAuthCallback = (event: Event) => {
+ const callbackUrl = (event as CustomEvent).detail;
+ if (!callbackUrl) {
+ return;
+ }
+
+ try {
+ const parsed = new URL(callbackUrl);
+ if (parsed.searchParams.get("flow") !== "debug") {
+ return;
+ }
+
+ const error = parsed.searchParams.get("error");
+ const errorDescription = parsed.searchParams.get("error_description");
+ if (error) {
+ updateFlowState({
+ latestError: new Error(errorDescription ?? error),
+ validationError: null,
+ });
+ return;
+ }
+
+ const code = parsed.searchParams.get("code");
+ if (!code || processedElectronCodeRef.current === code) {
+ return;
+ }
+
+ processedElectronCodeRef.current = code;
+ updateFlowState({
+ authorizationCode: code,
+ latestError: null,
+ validationError: null,
+ statusMessage: {
+ type: "success",
+ message:
+ "Authorization received from your browser. Click Continue to exchange the code.",
+ },
+ });
+ } catch (error) {
+ console.error("Failed to process Electron OAuth callback:", error);
+ }
+ };
+
+ window.addEventListener(
+ "electron-oauth-callback",
+ handleElectronOAuthCallback as EventListener,
+ );
+
+ return () => {
+ window.removeEventListener(
+ "electron-oauth-callback",
+ handleElectronOAuthCallback as EventListener,
+ );
+ };
+ }, [updateFlowState]);
+
// Helper to get step props
const getStepProps = (stepName: OAuthStep) => ({
isComplete:
diff --git a/mcpjam-inspector/client/src/components/oauth/__tests__/OAuthDebugCallback.test.tsx b/mcpjam-inspector/client/src/components/oauth/__tests__/OAuthDebugCallback.test.tsx
new file mode 100644
index 000000000..13f125f3c
--- /dev/null
+++ b/mcpjam-inspector/client/src/components/oauth/__tests__/OAuthDebugCallback.test.tsx
@@ -0,0 +1,21 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { buildElectronDebugCallbackUrl } from "../OAuthDebugCallback";
+
+describe("OAuthDebugCallback", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ window.isElectron = false;
+ window.name = "";
+ window.history.replaceState(
+ {},
+ "",
+ "/oauth/callback/debug?code=test-code&state=test-state",
+ );
+ });
+
+ it("builds the Electron deep-link callback URL for browser returns", () => {
+ expect(buildElectronDebugCallbackUrl()).toBe(
+ "mcpjam://oauth/callback?flow=debug&code=test-code&state=test-state",
+ );
+ });
+});
diff --git a/mcpjam-inspector/client/src/components/setting/AccountApiKeySection.tsx b/mcpjam-inspector/client/src/components/setting/AccountApiKeySection.tsx
index b7a4737e4..4de247924 100644
--- a/mcpjam-inspector/client/src/components/setting/AccountApiKeySection.tsx
+++ b/mcpjam-inspector/client/src/components/setting/AccountApiKeySection.tsx
@@ -26,9 +26,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useConvexAuth, useMutation, useQuery } from "convex/react";
-import { useAuth } from "@workos-inc/authkit-react";
import { usePostHog } from "posthog-js/react";
import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils";
+import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth";
type CopyFieldProps = {
value: string;
@@ -93,7 +93,7 @@ export function AccountApiKeySection({
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth();
- const { signIn } = useAuth();
+ const { signIn } = useElectronHostedAuth();
const posthog = usePostHog();
const maybeApiKey = useQuery(
diff --git a/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx b/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx
index 2c4dacce8..777dc129c 100644
--- a/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx
+++ b/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx
@@ -1,5 +1,4 @@
import { useState } from "react";
-import { useAuth } from "@workos-inc/authkit-react";
import { useConvexAuth, useQuery } from "convex/react";
import {
DropdownMenu,
@@ -32,6 +31,7 @@ import { useOrganizationQueries } from "@/hooks/useOrganizations";
import { CreateOrganizationDialog } from "@/components/organization/CreateOrganizationDialog";
import { HOSTED_MODE } from "@/lib/config";
import { Button } from "@/components/ui/button";
+import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth";
export function SidebarUser({
activeOrganizationId,
@@ -39,7 +39,7 @@ export function SidebarUser({
activeOrganizationId?: string;
}) {
const { isLoading, isAuthenticated } = useConvexAuth();
- const { user, signIn, signOut } = useAuth();
+ const { user, signIn, signOut } = useElectronHostedAuth();
const { profilePictureUrl } = useProfilePicture();
const convexUser = useQuery("users:getCurrentUser" as any);
const { isMobile } = useSidebar();
@@ -63,12 +63,12 @@ export function SidebarUser({
const subtitle = activeOrgName || email;
const handleSignOut = () => {
- const isElectron = (window as any).isElectron;
- const returnTo =
- isElectron && import.meta.env.DEV
- ? "http://localhost:8080/callback"
- : window.location.origin;
- signOut({ returnTo });
+ if (window.isElectron) {
+ void signOut();
+ return;
+ }
+
+ void signOut({ returnTo: window.location.origin });
};
const avatarUrl = profilePictureUrl;
diff --git a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx
index 2fabc01f7..093e2a92f 100644
--- a/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx
+++ b/mcpjam-inspector/client/src/components/ui-playground/PlaygroundMain.tsx
@@ -13,7 +13,6 @@
import { FormEvent, useState, useEffect, useCallback, useMemo } from "react";
import { ArrowDown, Braces, Loader2, Trash2 } from "lucide-react";
-import { useAuth } from "@workos-inc/authkit-react";
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
import { ModelDefinition } from "@/shared/types";
import { cn } from "@/lib/utils";
@@ -30,6 +29,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth";
import { createDeterministicToolMessages } from "./playground-helpers";
import type { MCPPromptResult } from "@/components/chat-v2/chat-input/prompts/mcp-prompts-popover";
import type { SkillResult } from "@/components/chat-v2/chat-input/skills/skill-types";
@@ -175,7 +175,7 @@ export function PlaygroundMain({
hideSaveViewButton = false,
disabledInputPlaceholder = "Input disabled in Views",
}: PlaygroundMainProps) {
- const { signUp } = useAuth();
+ const { signUp } = useElectronHostedAuth();
const posthog = usePostHog();
const clearLogs = useTrafficLogStore((s) => s.clear);
const [input, setInput] = useState("");
diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
index e88223f92..d6a31008e 100644
--- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
+++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
@@ -1,15 +1,25 @@
-import { renderHook, waitFor } from "@testing-library/react";
+import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppState, AppAction } from "@/state/app-types";
-import { useServerState } from "../use-server-state";
+import {
+ buildElectronMcpCallbackUrl,
+ shouldRetryOAuthConnectionFailure,
+ useServerState,
+} from "../use-server-state";
+import { testConnection } from "@/state/mcp-api";
+import { getStoredTokens } from "@/lib/oauth/mcp-oauth";
-const { toastError, toastSuccess, handleOAuthCallbackMock } = vi.hoisted(
- () => ({
- toastError: vi.fn(),
- toastSuccess: vi.fn(),
- handleOAuthCallbackMock: vi.fn(),
- }),
-);
+const {
+ toastError,
+ toastSuccess,
+ handleOAuthCallbackMock,
+ getStoredTokensMock,
+} = vi.hoisted(() => ({
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+ handleOAuthCallbackMock: vi.fn(),
+ getStoredTokensMock: vi.fn(),
+}));
vi.mock("sonner", () => ({
toast: {
@@ -23,19 +33,25 @@ vi.mock("@/state/mcp-api", () => ({
deleteServer: vi.fn(),
listServers: vi.fn(),
reconnectServer: vi.fn(),
- getInitializationInfo: vi.fn(),
+ getInitializationInfo: vi.fn().mockResolvedValue({
+ success: false,
+ }),
}));
vi.mock("@/state/oauth-orchestrator", () => ({
ensureAuthorizedForReconnect: vi.fn(),
}));
-vi.mock("@/lib/oauth/mcp-oauth", () => ({
- handleOAuthCallback: handleOAuthCallbackMock,
- getStoredTokens: vi.fn(),
- clearOAuthData: vi.fn(),
- initiateOAuth: vi.fn(),
-}));
+vi.mock("@/lib/oauth/mcp-oauth", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ handleOAuthCallback: handleOAuthCallbackMock,
+ getStoredTokens: getStoredTokensMock,
+ clearOAuthData: vi.fn(),
+ initiateOAuth: vi.fn(),
+ };
+});
vi.mock("@/lib/apis/web/context", () => ({
injectHostedServerMapping: vi.fn(),
@@ -133,6 +149,12 @@ function renderUseServerState(dispatch: (action: AppAction) => void) {
);
}
+async function flushAsyncWork(iterations = 5): Promise {
+ for (let index = 0; index < iterations; index += 1) {
+ await Promise.resolve();
+ }
+}
+
describe("useServerState OAuth callback failures", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -193,4 +215,110 @@ describe("useServerState OAuth callback failures", () => {
);
expect(localStorage.getItem("mcp-oauth-pending")).toBeNull();
});
+
+ it("bounces browser OAuth callbacks back into Electron when the OAuth state is tagged for desktop", async () => {
+ window.isElectron = false;
+ window.history.replaceState(
+ {},
+ "",
+ "/oauth/callback?code=test-code&state=electron_mcp:test-state",
+ );
+
+ expect(buildElectronMcpCallbackUrl()).toBe(
+ "mcpjam://oauth/callback?flow=mcp&code=test-code&state=electron_mcp%3Atest-state",
+ );
+ });
+
+ it("ignores regular browser OAuth callbacks that are not tagged for Electron", () => {
+ window.isElectron = false;
+ window.history.replaceState(
+ {},
+ "",
+ "/oauth/callback?code=test-code&state=test-state",
+ );
+
+ expect(buildElectronMcpCallbackUrl()).toBeNull();
+ });
+
+ it("detects retryable transport errors after OAuth", () => {
+ expect(
+ shouldRetryOAuthConnectionFailure(
+ "Streamable HTTP error: Request timed out. SSE error: SSE error: Non-200 status code (404).",
+ ),
+ ).toBe(true);
+ expect(
+ shouldRetryOAuthConnectionFailure(
+ "OAuth failed with invalid_client from the authorization server",
+ ),
+ ).toBe(false);
+ });
+
+ it("retries transient connection failures once after a successful OAuth callback", async () => {
+ vi.useFakeTimers();
+
+ localStorage.setItem("mcp-oauth-pending", "demo-server");
+ localStorage.setItem(
+ "mcp-serverUrl-demo-server",
+ "https://example.com/mcp",
+ );
+ localStorage.setItem("mcp-oauth-return-hash", "#demo-server");
+ window.history.replaceState({}, "", "/oauth/callback?code=test-code");
+
+ handleOAuthCallbackMock.mockResolvedValue({
+ success: true,
+ serverName: "demo-server",
+ serverConfig: {
+ url: "https://example.com/mcp",
+ requestInit: {
+ headers: {
+ Authorization: "Bearer token",
+ },
+ },
+ },
+ });
+ vi.mocked(getStoredTokens).mockReturnValue({
+ access_token: "token",
+ } as any);
+ vi.mocked(testConnection)
+ .mockResolvedValueOnce({
+ success: false,
+ error:
+ 'Connection failed for server demo-server: Failed to connect to MCP server "demo-server" using HTTP transports. Streamable HTTP error: Request timed out. SSE error: SSE error: Non-200 status code (404).',
+ } as any)
+ .mockResolvedValueOnce({
+ success: true,
+ initInfo: null,
+ } as any);
+
+ try {
+ const dispatch = vi.fn();
+ renderUseServerState(dispatch);
+
+ await act(async () => {
+ await flushAsyncWork();
+ });
+
+ expect(handleOAuthCallbackMock).toHaveBeenCalledWith("test-code");
+ expect(testConnection).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(1500);
+ await flushAsyncWork();
+ });
+
+ expect(testConnection).toHaveBeenCalledTimes(2);
+
+ expect(toastSuccess).toHaveBeenCalledWith(
+ "OAuth connection successful! Connected to demo-server.",
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "CONNECT_SUCCESS",
+ name: "demo-server",
+ }),
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx
new file mode 100644
index 000000000..afdff240a
--- /dev/null
+++ b/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx
@@ -0,0 +1,165 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ useAuthMock,
+ createClientMock,
+ getWorkosClientIdMock,
+ getWorkosClientOptionsMock,
+ getWorkosDevModeMock,
+ getWorkosRedirectUriMock,
+} = vi.hoisted(() => ({
+ useAuthMock: vi.fn(),
+ createClientMock: vi.fn(),
+ getWorkosClientIdMock: vi.fn(),
+ getWorkosClientOptionsMock: vi.fn(),
+ getWorkosDevModeMock: vi.fn(),
+ getWorkosRedirectUriMock: vi.fn(),
+}));
+
+vi.mock("@workos-inc/authkit-react", () => ({
+ useAuth: useAuthMock,
+}));
+
+vi.mock("@workos-inc/authkit-js", () => ({
+ createClient: createClientMock,
+}));
+
+vi.mock("@/lib/workos-config", () => ({
+ getWorkosClientId: getWorkosClientIdMock,
+ getWorkosClientOptions: getWorkosClientOptionsMock,
+ getWorkosDevMode: getWorkosDevModeMock,
+ getWorkosRedirectUri: getWorkosRedirectUriMock,
+}));
+
+import { useElectronHostedAuth } from "../useElectronHostedAuth";
+
+describe("useElectronHostedAuth", () => {
+ const defaultSignIn = vi.fn();
+ const defaultSignUp = vi.fn();
+ const defaultSignOut = vi.fn();
+ const openExternal = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ window.isElectron = false;
+ window.electronAPI = {
+ app: {
+ openExternal,
+ },
+ } as any;
+
+ useAuthMock.mockReturnValue({
+ signIn: defaultSignIn,
+ signUp: defaultSignUp,
+ signOut: defaultSignOut,
+ user: null,
+ isLoading: false,
+ });
+
+ getWorkosClientIdMock.mockReturnValue("workos-client-id");
+ getWorkosClientOptionsMock.mockReturnValue({
+ apiHostname: "api.workos.test",
+ });
+ getWorkosDevModeMock.mockReturnValue(true);
+ getWorkosRedirectUriMock.mockReturnValue("mcpjam://oauth/callback");
+ });
+
+ it("falls back to the default AuthKit signIn outside Electron", async () => {
+ const { result } = renderHook(() => useElectronHostedAuth());
+
+ await act(async () => {
+ await result.current.signIn({ screenHint: "sign-in" } as any);
+ });
+
+ expect(defaultSignIn).toHaveBeenCalledWith({ screenHint: "sign-in" });
+ expect(createClientMock).not.toHaveBeenCalled();
+ expect(openExternal).not.toHaveBeenCalled();
+ });
+
+ it("opens hosted sign-in in the system browser in Electron", async () => {
+ const dispose = vi.fn();
+ const getSignInUrl = vi.fn().mockResolvedValue("https://auth.example.com");
+ createClientMock.mockResolvedValue({
+ getSignInUrl,
+ getSignUpUrl: vi.fn(),
+ dispose,
+ });
+ window.isElectron = true;
+
+ const { result } = renderHook(() => useElectronHostedAuth());
+
+ await act(async () => {
+ await result.current.signIn({ screenHint: "sign-in" } as any);
+ });
+
+ expect(createClientMock).toHaveBeenCalledWith("workos-client-id", {
+ redirectUri: "mcpjam://oauth/callback",
+ devMode: true,
+ apiHostname: "api.workos.test",
+ });
+ expect(getSignInUrl).toHaveBeenCalledWith({ screenHint: "sign-in" });
+ expect(openExternal).toHaveBeenCalledWith("https://auth.example.com");
+ expect(dispose).toHaveBeenCalled();
+ expect(defaultSignIn).not.toHaveBeenCalled();
+ });
+
+ it("falls back to default AuthKit navigation when the client ID is missing", async () => {
+ getWorkosClientIdMock.mockReturnValue("");
+ window.isElectron = true;
+
+ const { result } = renderHook(() => useElectronHostedAuth());
+
+ await act(async () => {
+ await result.current.signUp({ screenHint: "sign-up" } as any);
+ });
+
+ expect(defaultSignUp).toHaveBeenCalledWith({ screenHint: "sign-up" });
+ expect(createClientMock).not.toHaveBeenCalled();
+ expect(openExternal).not.toHaveBeenCalled();
+ });
+
+ it("signs out in the background and returns Electron to a safe in-app path", async () => {
+ const dispose = vi.fn();
+ const clientSignOut = vi.fn().mockResolvedValue(undefined);
+ const authResetListener = vi.fn();
+ createClientMock.mockResolvedValue({
+ getSignInUrl: vi.fn(),
+ getSignUpUrl: vi.fn(),
+ signOut: clientSignOut,
+ dispose,
+ });
+ window.isElectron = true;
+ window.history.replaceState({}, "", "/profile?tab=account#settings");
+ window.addEventListener("electron-auth-reset", authResetListener);
+
+ try {
+ const { result } = renderHook(() => useElectronHostedAuth());
+
+ await act(async () => {
+ await result.current.signOut({
+ returnTo: "http://localhost:8080/callback",
+ } as any);
+ });
+
+ expect(createClientMock).toHaveBeenCalledWith("workos-client-id", {
+ redirectUri: "mcpjam://oauth/callback",
+ devMode: true,
+ apiHostname: "api.workos.test",
+ });
+ expect(clientSignOut).toHaveBeenCalledWith({
+ returnTo: window.location.origin,
+ navigate: false,
+ });
+ expect(window.location.pathname).toBe("/");
+ expect(window.location.search).toBe("");
+ expect(window.location.hash).toBe("");
+ expect(authResetListener).toHaveBeenCalledTimes(1);
+ expect(defaultSignOut).not.toHaveBeenCalled();
+ expect(dispose).toHaveBeenCalled();
+ } finally {
+ window.removeEventListener("electron-auth-reset", authResetListener);
+ }
+ });
+});
diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts
index 98ccf6ece..2cca56625 100644
--- a/mcpjam-inspector/client/src/hooks/use-server-state.ts
+++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts
@@ -25,6 +25,7 @@ import {
getStoredTokens,
clearOAuthData,
initiateOAuth,
+ isElectronMcpCallbackState,
} from "@/lib/oauth/mcp-oauth";
import { getHostedOAuthCallbackContext } from "@/lib/hosted-oauth-callback";
import { HOSTED_MODE } from "@/lib/config";
@@ -74,6 +75,64 @@ function saveOAuthConfigToLocalStorage(formData: ServerFormData): void {
}
}
+export function buildElectronMcpCallbackUrl(): string | null {
+ if (window.isElectron || window.location.pathname !== "/oauth/callback") {
+ return null;
+ }
+
+ const params = new URLSearchParams(window.location.search);
+ if (!params.get("code") && !params.get("error")) {
+ return null;
+ }
+
+ // Electron-started MCP OAuth explicitly tags the state parameter so the
+ // browser callback can hand control back to the desktop app without relying
+ // on browser-local storage heuristics.
+ if (!isElectronMcpCallbackState(params.get("state"))) {
+ return null;
+ }
+
+ const callbackUrl = new URL("mcpjam://oauth/callback");
+ callbackUrl.searchParams.set("flow", "mcp");
+
+ for (const [key, value] of params.entries()) {
+ callbackUrl.searchParams.append(key, value);
+ }
+
+ return callbackUrl.toString();
+}
+
+const OAUTH_CONNECTION_RETRY_DELAY_MS = 1500;
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+}
+
+export function shouldRetryOAuthConnectionFailure(
+ errorMessage?: string,
+): boolean {
+ if (!errorMessage) {
+ return false;
+ }
+
+ const normalized = errorMessage.toLowerCase();
+ if (
+ normalized.includes("authentication failed") ||
+ normalized.includes("invalid_client") ||
+ normalized.includes("unauthorized_client")
+ ) {
+ return false;
+ }
+
+ return (
+ normalized.includes("request timed out") ||
+ normalized.includes("streamable http error") ||
+ normalized.includes("sse error: sse error: non-200 status code (404)")
+ );
+}
+
interface LoggerLike {
info: (message: string, meta?: Record) => void;
warn: (message: string, meta?: Record) => void;
@@ -410,6 +469,43 @@ export function useServerState({
[dispatch, fetchAndStoreInitInfo],
);
+ const testConnectionAfterOAuth = useCallback(
+ async (serverConfig: HttpServerConfig, serverName: string) => {
+ try {
+ const firstResult = await testConnection(serverConfig, serverName);
+ if (
+ firstResult.success ||
+ !shouldRetryOAuthConnectionFailure(firstResult.error)
+ ) {
+ return firstResult;
+ }
+
+ logger.warn(
+ "Retrying OAuth connection after transient transport error",
+ {
+ serverName,
+ error: firstResult.error,
+ },
+ );
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown connection error";
+ if (!shouldRetryOAuthConnectionFailure(errorMessage)) {
+ throw error;
+ }
+
+ logger.warn("Retrying OAuth connection after transport exception", {
+ serverName,
+ error: errorMessage,
+ });
+ }
+
+ await delay(OAUTH_CONNECTION_RETRY_DELAY_MS);
+ return testConnection(serverConfig, serverName);
+ },
+ [logger],
+ );
+
const handleOAuthCallbackComplete = useCallback(
async (code: string) => {
const pendingServerName = localStorage.getItem("mcp-oauth-pending");
@@ -430,7 +526,7 @@ export function useServerState({
});
try {
- const connectionResult = await testConnection(
+ const connectionResult = await testConnectionAfterOAuth(
result.serverConfig,
serverName,
);
@@ -504,7 +600,13 @@ export function useServerState({
}
}
},
- [dispatch, failPendingOAuthConnection, logger, storeInitInfo],
+ [
+ dispatch,
+ failPendingOAuthConnection,
+ logger,
+ storeInitInfo,
+ testConnectionAfterOAuth,
+ ],
);
useEffect(() => {
@@ -527,9 +629,14 @@ export function useServerState({
const code = urlParams.get("code");
const error = urlParams.get("error");
const errorDescription = urlParams.get("error_description");
+ const electronCallbackUrl = buildElectronMcpCallbackUrl();
const hostedOAuthCallbackContext = HOSTED_MODE
? getHostedOAuthCallbackContext()
: null;
+ if (electronCallbackUrl) {
+ window.location.replace(electronCallbackUrl);
+ return;
+ }
if (code) {
if (hostedOAuthCallbackContext) {
return; // Handled by App.tsx hosted OAuth interception
diff --git a/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts
new file mode 100644
index 000000000..fb338606e
--- /dev/null
+++ b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts
@@ -0,0 +1,145 @@
+import { useCallback } from "react";
+import { createClient } from "@workos-inc/authkit-js";
+import { useAuth } from "@workos-inc/authkit-react";
+import {
+ getWorkosClientId,
+ getWorkosClientOptions,
+ getWorkosDevMode,
+ getWorkosRedirectUri,
+} from "@/lib/workos-config";
+
+function resolveElectronSignOutPath(returnTo?: string): string {
+ if (!returnTo) {
+ return "/";
+ }
+
+ try {
+ const parsed = new URL(returnTo, window.location.origin);
+ if (parsed.origin !== window.location.origin) {
+ return "/";
+ }
+
+ if (parsed.pathname === "/callback") {
+ return "/";
+ }
+
+ return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
+ } catch {
+ return "/";
+ }
+}
+
+export function useElectronHostedAuth() {
+ const auth = useAuth();
+ const defaultSignIn = auth.signIn;
+ const defaultSignUp = auth.signUp;
+ const defaultSignOut = auth.signOut;
+
+ const openHostedAuth = useCallback(
+ async (
+ mode: "signIn" | "signUp",
+ opts?: Parameters[0],
+ ): Promise => {
+ if (!window.isElectron) {
+ if (mode === "signIn") {
+ return defaultSignIn(opts);
+ }
+ return defaultSignUp(opts);
+ }
+
+ const clientId = getWorkosClientId();
+ if (!clientId) {
+ console.warn(
+ "[auth] Missing WorkOS client ID in Electron; falling back to default AuthKit navigation.",
+ );
+ if (mode === "signIn") {
+ return defaultSignIn(opts);
+ }
+ return defaultSignUp(opts);
+ }
+
+ const client = await createClient(clientId, {
+ redirectUri: getWorkosRedirectUri(),
+ devMode: getWorkosDevMode(),
+ ...getWorkosClientOptions(),
+ });
+
+ try {
+ const url =
+ mode === "signIn"
+ ? await client.getSignInUrl(opts)
+ : await client.getSignUpUrl(opts);
+
+ if (window.electronAPI?.app?.openExternal) {
+ await window.electronAPI.app.openExternal(url);
+ return;
+ }
+
+ console.warn(
+ "[auth] Electron openExternal bridge unavailable; falling back to in-app navigation guard.",
+ );
+ window.location.assign(url);
+ } finally {
+ client.dispose();
+ }
+ },
+ [defaultSignIn, defaultSignUp],
+ );
+
+ const signIn = useCallback(
+ (opts?: Parameters[0]) =>
+ openHostedAuth("signIn", opts),
+ [openHostedAuth],
+ );
+
+ const signUp = useCallback(
+ (opts?: Parameters[0]) =>
+ openHostedAuth("signUp", opts),
+ [openHostedAuth],
+ );
+
+ const signOut = useCallback(
+ async (opts?: Parameters[0]) => {
+ if (!window.isElectron) {
+ return defaultSignOut(opts);
+ }
+
+ const clientId = getWorkosClientId();
+ if (!clientId) {
+ console.warn(
+ "[auth] Missing WorkOS client ID in Electron; falling back to default AuthKit logout.",
+ );
+ return defaultSignOut(opts);
+ }
+
+ const client = await createClient(clientId, {
+ redirectUri: getWorkosRedirectUri(),
+ devMode: getWorkosDevMode(),
+ ...getWorkosClientOptions(),
+ });
+
+ try {
+ await client.signOut({
+ returnTo: window.location.origin,
+ navigate: false,
+ });
+ } finally {
+ client.dispose();
+ }
+
+ const nextPath = resolveElectronSignOutPath(opts?.returnTo);
+ window.history.replaceState({}, "", nextPath);
+ window.dispatchEvent(new PopStateEvent("popstate"));
+ window.dispatchEvent(new Event("hashchange"));
+ window.dispatchEvent(new Event("electron-auth-reset"));
+ },
+ [defaultSignOut],
+ );
+
+ return {
+ ...auth,
+ signIn,
+ signUp,
+ signOut,
+ };
+}
diff --git a/mcpjam-inspector/client/src/hooks/useElectronOAuth.ts b/mcpjam-inspector/client/src/hooks/useElectronOAuth.ts
index a6b486f73..1cb4220fa 100644
--- a/mcpjam-inspector/client/src/hooks/useElectronOAuth.ts
+++ b/mcpjam-inspector/client/src/hooks/useElectronOAuth.ts
@@ -8,13 +8,22 @@ export function useElectronOAuth() {
}
const handleOAuthCallback = (url: string) => {
- console.log("Electron OAuth callback received:", url);
-
try {
// Parse the callback URL to extract tokens/parameters
const urlObj = new URL(url);
+ const flow = urlObj.searchParams.get("flow");
const params = new URLSearchParams(urlObj.search);
+ window.dispatchEvent(
+ new CustomEvent("electron-oauth-callback", {
+ detail: url,
+ }),
+ );
+
+ if (flow === "mcp" || flow === "debug") {
+ return;
+ }
+
// Extract the code and state from the callback
const code = params.get("code");
const state = params.get("state");
@@ -26,8 +35,6 @@ export function useElectronOAuth() {
}
if (code) {
- console.log("OAuth code received, redirecting to callback page");
-
// Redirect to the callback page with the code and state
// This mimics what would happen in a browser OAuth flow
const callbackUrl = new URL("/callback", window.location.origin);
diff --git a/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts b/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts
index 1ce795fc5..dc64de004 100644
--- a/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts
+++ b/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts
@@ -240,18 +240,28 @@ describe("guest-session module", () => {
});
it("uses session with exactly 5 min + 1ms remaining (valid)", async () => {
- const session = {
- guestId: "edge-guest",
- token: "edge-token",
- expiresAt: Date.now() + 5 * 60 * 1000 + 1, // 5 min + 1ms buffer
- };
-
- vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(session));
-
- const result = await guestSession.getOrCreateGuestSession();
-
- expect(result).toEqual(session);
- expect(global.fetch).not.toHaveBeenCalled();
+ vi.useFakeTimers();
+ const now = new Date("2026-03-20T22:37:53.000Z");
+ vi.setSystemTime(now);
+
+ try {
+ const session = {
+ guestId: "edge-guest",
+ token: "edge-token",
+ expiresAt: now.getTime() + 5 * 60 * 1000 + 1, // 5 min + 1ms buffer
+ };
+
+ vi.mocked(localStorage.getItem).mockReturnValue(
+ JSON.stringify(session),
+ );
+
+ const result = await guestSession.getOrCreateGuestSession();
+
+ expect(result).toEqual(session);
+ expect(global.fetch).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
});
});
diff --git a/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts b/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts
new file mode 100644
index 000000000..757db9bbf
--- /dev/null
+++ b/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts
@@ -0,0 +1,40 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { getWorkosDevMode, getWorkosRedirectUri } from "../workos-config";
+
+describe("workos-config", () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs();
+ window.isElectron = false;
+ window.history.replaceState({}, "", "/");
+ });
+
+ it("returns the app callback path in the browser", () => {
+ expect(getWorkosRedirectUri()).toBe(`${window.location.origin}/callback`);
+ });
+
+ it("prefers the Electron deep link callback inside Electron", () => {
+ window.isElectron = true;
+
+ expect(getWorkosRedirectUri()).toBe("mcpjam://oauth/callback");
+ });
+
+ it("allows an explicit redirect URI override in Electron", () => {
+ vi.stubEnv(
+ "VITE_WORKOS_REDIRECT_URI",
+ "https://override.example.com/callback",
+ );
+ window.isElectron = true;
+
+ expect(getWorkosRedirectUri()).toBe(
+ "https://override.example.com/callback",
+ );
+ });
+
+ it("respects explicit devMode environment overrides", () => {
+ vi.stubEnv("VITE_WORKOS_DEV_MODE", "false");
+ expect(getWorkosDevMode()).toBe(false);
+
+ vi.stubEnv("VITE_WORKOS_DEV_MODE", "true");
+ expect(getWorkosDevMode()).toBe(true);
+ });
+});
diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts
index 3ad555b0b..c319a71e6 100644
--- a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts
@@ -48,6 +48,8 @@ describe("mcp-oauth", () => {
localStorage.clear();
sessionStorage.clear();
mockSdkAuth.mockReset();
+ window.isElectron = false;
+ delete window.electronAPI;
const sessionToken = await import("@/lib/session-token");
authFetch = sessionToken.authFetch as ReturnType;
@@ -58,6 +60,8 @@ describe("mcp-oauth", () => {
vi.restoreAllMocks();
localStorage.clear();
sessionStorage.clear();
+ window.isElectron = false;
+ delete window.electronAPI;
});
describe("proxy endpoint auth failures", () => {
@@ -159,6 +163,66 @@ describe("mcp-oauth", () => {
});
describe("persisted discovery state", () => {
+ it("tags Electron-started OAuth state for desktop callback recovery", async () => {
+ const { MCPOAuthProvider } = await import("../mcp-oauth");
+ const provider = new MCPOAuthProvider(
+ "asana",
+ "https://mcp.asana.com/sse",
+ );
+
+ window.isElectron = true;
+
+ expect(provider.state()).toMatch(/^electron_mcp:mock-random-string$/);
+ });
+
+ it("keeps browser OAuth state untagged", async () => {
+ const { MCPOAuthProvider } = await import("../mcp-oauth");
+ const provider = new MCPOAuthProvider(
+ "asana",
+ "https://mcp.asana.com/sse",
+ );
+
+ window.isElectron = false;
+
+ expect(provider.state()).toBe("mock-random-string");
+ });
+
+ it("falls back to in-app navigation when Electron browser open fails", async () => {
+ const { MCPOAuthProvider } = await import("../mcp-oauth");
+ const provider = new MCPOAuthProvider(
+ "asana",
+ "https://mcp.asana.com/sse",
+ );
+ const navigateSpy = vi
+ .spyOn(provider, "navigateToUrl")
+ .mockImplementation(() => {});
+ const consoleErrorSpy = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ const openExternal = vi
+ .fn()
+ .mockRejectedValue(new Error("system browser unavailable"));
+
+ window.isElectron = true;
+ window.electronAPI = {
+ app: {
+ openExternal,
+ },
+ } as any;
+
+ await provider.redirectToAuthorization(
+ new URL("https://auth.example.com/authorize"),
+ );
+
+ expect(openExternal).toHaveBeenCalledWith(
+ "https://auth.example.com/authorize",
+ );
+ expect(navigateSpy).toHaveBeenCalledWith(
+ "https://auth.example.com/authorize",
+ );
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+
it("round-trips discovery state for the matching server URL", async () => {
const { MCPOAuthProvider } = await import("../mcp-oauth");
const discoveryState = createDiscoveryState();
diff --git a/mcpjam-inspector/client/src/lib/oauth/constants.ts b/mcpjam-inspector/client/src/lib/oauth/constants.ts
index 4efa4544b..60e5f28f0 100644
--- a/mcpjam-inspector/client/src/lib/oauth/constants.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/constants.ts
@@ -11,24 +11,10 @@ export const MCPJAM_CLIENT_ID =
"https://www.mcpjam.com/.well-known/oauth/client-metadata.json";
export function getRedirectUri(): string {
- // Check if running in Electron with custom protocol support
- if (typeof window !== "undefined" && (window as any).electron) {
- return "mcpjam://oauth/callback";
- }
-
- // In browser, detect current port
if (typeof window !== "undefined") {
- const port = window.location.port;
-
- // Production (no port or port 443)
- if (!port || port === "443") {
- return "https://www.mcpjam.com/oauth/callback";
- }
-
- // Local development - use detected port
- return `http://localhost:${port}/oauth/callback`;
+ return `${window.location.origin}/oauth/callback/debug`;
}
// Default fallback
- return "http://localhost:6274/oauth/callback";
+ return "http://localhost:6274/oauth/callback/debug";
}
diff --git a/mcpjam-inspector/client/src/lib/oauth/debug-oauth-provider.ts b/mcpjam-inspector/client/src/lib/oauth/debug-oauth-provider.ts
index e2af2740a..2188772d4 100644
--- a/mcpjam-inspector/client/src/lib/oauth/debug-oauth-provider.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/debug-oauth-provider.ts
@@ -17,8 +17,6 @@ export class DebugMCPOAuthClientProvider implements OAuthClientProvider {
}
get redirectUrl(): string {
- // For debugging, we can also try using a simpler redirect URL
- // or fall back to a localhost URL if the current origin has issues
const origin = window.location.origin;
return `${origin}/oauth/callback/debug`;
diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
index af6f2d8c4..2be999c2f 100644
--- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
@@ -21,6 +21,16 @@ interface StoredOAuthDiscoveryState {
discoveryState: OAuthDiscoveryState;
}
+const ELECTRON_MCP_CALLBACK_STATE_PREFIX = "electron_mcp:";
+
+function getMCPOAuthRedirectUri(): string {
+ if (typeof window !== "undefined") {
+ return `${window.location.origin}/oauth/callback`;
+ }
+
+ return "http://localhost:6274/oauth/callback";
+}
+
function getDiscoveryStorageKey(serverName: string): string {
return `mcp-discovery-${serverName}`;
}
@@ -124,6 +134,20 @@ export interface OAuthResult {
error?: string;
}
+export function buildMCPOAuthState(): string {
+ const state = generateRandomString(32);
+ if (window.isElectron) {
+ return `${ELECTRON_MCP_CALLBACK_STATE_PREFIX}${state}`;
+ }
+ return state;
+}
+
+export function isElectronMcpCallbackState(
+ state: string | null | undefined,
+): boolean {
+ return Boolean(state && state.startsWith(ELECTRON_MCP_CALLBACK_STATE_PREFIX));
+}
+
/**
* Simple localStorage-based OAuth provider for MCP
*/
@@ -142,13 +166,13 @@ export class MCPOAuthProvider implements OAuthClientProvider {
) {
this.serverName = serverName;
this.serverUrl = serverUrl;
- this.redirectUri = `${window.location.origin}/oauth/callback`;
+ this.redirectUri = getMCPOAuthRedirectUri();
this.customClientId = customClientId;
this.customClientSecret = customClientSecret;
}
state(): string {
- return generateRandomString(32);
+ return buildMCPOAuthState();
}
get redirectUrl(): string {
@@ -260,7 +284,24 @@ export class MCPOAuthProvider implements OAuthClientProvider {
if (window.location.hash) {
localStorage.setItem("mcp-oauth-return-hash", window.location.hash);
}
- window.location.href = authorizationUrl.toString();
+
+ if (window.isElectron && window.electronAPI?.app?.openExternal) {
+ try {
+ await window.electronAPI.app.openExternal(authorizationUrl.toString());
+ return;
+ } catch (error) {
+ console.error(
+ "Failed to open system browser for MCP OAuth, falling back to in-app navigation:",
+ error,
+ );
+ }
+ }
+
+ this.navigateToUrl(authorizationUrl.toString());
+ }
+
+ navigateToUrl(url: string) {
+ window.location.assign(url);
}
async saveCodeVerifier(codeVerifier: string) {
diff --git a/mcpjam-inspector/client/src/lib/workos-config.ts b/mcpjam-inspector/client/src/lib/workos-config.ts
new file mode 100644
index 000000000..abc42cf09
--- /dev/null
+++ b/mcpjam-inspector/client/src/lib/workos-config.ts
@@ -0,0 +1,56 @@
+import { createClient } from "@workos-inc/authkit-js";
+
+type WorkosClientOptions = NonNullable[1]>;
+
+export function getWorkosClientId(): string {
+ return import.meta.env.VITE_WORKOS_CLIENT_ID as string;
+}
+
+export function getWorkosDevMode(): boolean {
+ const explicit = import.meta.env.VITE_WORKOS_DEV_MODE as string | undefined;
+ if (explicit === "true") return true;
+ if (explicit === "false") return false;
+ if (import.meta.env.DEV) return true;
+
+ return location.hostname === "localhost" || location.hostname === "127.0.0.1";
+}
+
+export function getWorkosRedirectUri(): string {
+ const envRedirect =
+ (import.meta.env.VITE_WORKOS_REDIRECT_URI as string) || undefined;
+ if (typeof window === "undefined") return envRedirect ?? "/callback";
+
+ if ((window as any)?.isElectron) {
+ return envRedirect ?? "mcpjam://oauth/callback";
+ }
+
+ const isBrowserHttp =
+ window.location.protocol === "http:" ||
+ window.location.protocol === "https:";
+ if (isBrowserHttp) return `${window.location.origin}/callback`;
+ if (envRedirect) return envRedirect;
+ return `${window.location.origin}/callback`;
+}
+
+export function getWorkosClientOptions(): WorkosClientOptions {
+ const envApiHostname = import.meta.env.VITE_WORKOS_API_HOSTNAME as
+ | string
+ | undefined;
+ if (envApiHostname) {
+ return { apiHostname: envApiHostname };
+ }
+
+ if (typeof window === "undefined") return {};
+ const disableProxy =
+ (import.meta.env.VITE_WORKOS_DISABLE_LOCAL_PROXY as string | undefined) ===
+ "true";
+ if (!import.meta.env.DEV || disableProxy) return {};
+
+ const { protocol, hostname, port } = window.location;
+ const parsedPort = port ? Number(port) : undefined;
+ return {
+ apiHostname: hostname,
+ https: protocol === "https:",
+ ...(parsedPort ? { port: parsedPort } : {}),
+ };
+}
diff --git a/mcpjam-inspector/client/src/main.tsx b/mcpjam-inspector/client/src/main.tsx
index d5b969bb4..47a11b6f7 100644
--- a/mcpjam-inspector/client/src/main.tsx
+++ b/mcpjam-inspector/client/src/main.tsx
@@ -1,4 +1,4 @@
-import { StrictMode } from "react";
+import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
@@ -11,6 +11,59 @@ import { initSentry } from "./lib/sentry.js";
import { IframeRouterError } from "./components/IframeRouterError.jsx";
import { initializeSessionToken } from "./lib/session-token.js";
import { HOSTED_MODE } from "./lib/config";
+import {
+ getWorkosClientId,
+ getWorkosClientOptions,
+ getWorkosDevMode,
+ getWorkosRedirectUri,
+} from "./lib/workos-config";
+
+interface RootProvidersProps {
+ convex: ConvexReactClient;
+ workosClientId: string;
+ workosRedirectUri: string;
+ workosDevMode: boolean;
+ workosClientOptions: ReturnType;
+}
+
+function RootProviders({
+ convex,
+ workosClientId,
+ workosRedirectUri,
+ workosDevMode,
+ workosClientOptions,
+}: RootProvidersProps) {
+ const [providerEpoch, setProviderEpoch] = useState(0);
+
+ useEffect(() => {
+ const handleAuthReset = () => {
+ setProviderEpoch((current) => current + 1);
+ };
+
+ window.addEventListener("electron-auth-reset", handleAuthReset);
+ return () => {
+ window.removeEventListener("electron-auth-reset", handleAuthReset);
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+ );
+}
// Initialize Sentry before React mounts
initSentry();
@@ -37,32 +90,9 @@ if (isInIframe) {
);
} else {
const convexUrl = import.meta.env.VITE_CONVEX_URL as string;
- const workosClientId = import.meta.env.VITE_WORKOS_CLIENT_ID as string;
- const workosDevMode = (() => {
- const explicit = import.meta.env.VITE_WORKOS_DEV_MODE as string | undefined;
- if (explicit === "true") return true;
- if (explicit === "false") return false;
- if (import.meta.env.DEV) return true;
- // Match SDK default: enable devMode on localhost so refresh tokens
- // persist in localStorage across hard refreshes for local prod builds.
- return (
- location.hostname === "localhost" || location.hostname === "127.0.0.1"
- );
- })();
-
- // Compute redirect URI safely across environments
- const workosRedirectUri = (() => {
- const envRedirect =
- (import.meta.env.VITE_WORKOS_REDIRECT_URI as string) || undefined;
- if (typeof window === "undefined") return envRedirect ?? "/callback";
- const isBrowserHttp =
- window.location.protocol === "http:" ||
- window.location.protocol === "https:";
- if (isBrowserHttp) return `${window.location.origin}/callback`;
- if (envRedirect) return envRedirect;
- if ((window as any)?.isElectron) return "mcpjam://oauth/callback";
- return `${window.location.origin}/callback`;
- })();
+ const workosClientId = getWorkosClientId();
+ const workosDevMode = getWorkosDevMode();
+ const workosRedirectUri = getWorkosRedirectUri();
// Warn if critical env vars are missing
if (!convexUrl) {
@@ -76,45 +106,10 @@ if (isInIframe) {
);
}
- const workosClientOptions = (() => {
- const envApiHostname = import.meta.env.VITE_WORKOS_API_HOSTNAME as
- | string
- | undefined;
- if (envApiHostname) {
- return { apiHostname: envApiHostname };
- }
-
- // Dev mode: proxy through Vite dev server to avoid CORS
- if (typeof window === "undefined") return {};
- const disableProxy =
- (import.meta.env.VITE_WORKOS_DISABLE_LOCAL_PROXY as
- | string
- | undefined) === "true";
- if (!import.meta.env.DEV || disableProxy) return {};
- const { protocol, hostname, port } = window.location;
- const parsedPort = port ? Number(port) : undefined;
- return {
- apiHostname: hostname,
- https: protocol === "https:",
- ...(parsedPort ? { port: parsedPort } : {}),
- };
- })();
+ const workosClientOptions = getWorkosClientOptions();
const convex = new ConvexReactClient(convexUrl);
- const Providers = (
-
-
-
-
-
- );
-
// Async bootstrap to initialize session token before rendering
async function bootstrap() {
const root = createRoot(document.getElementById("root")!);
@@ -185,7 +180,13 @@ if (isInIframe) {
root.render(
- {Providers}
+
,
);
diff --git a/mcpjam-inspector/client/src/types/electron.d.ts b/mcpjam-inspector/client/src/types/electron.d.ts
index 130169627..a54d64a71 100644
--- a/mcpjam-inspector/client/src/types/electron.d.ts
+++ b/mcpjam-inspector/client/src/types/electron.d.ts
@@ -8,6 +8,7 @@ export interface ElectronAPI {
app: {
getVersion: () => Promise;
getPlatform: () => Promise;
+ openExternal: (url: string) => Promise;
};
// File operations
diff --git a/mcpjam-inspector/src/ipc/app/app-listeners.ts b/mcpjam-inspector/src/ipc/app/app-listeners.ts
index 7e9c505b4..18979a061 100644
--- a/mcpjam-inspector/src/ipc/app/app-listeners.ts
+++ b/mcpjam-inspector/src/ipc/app/app-listeners.ts
@@ -1,4 +1,5 @@
-import { ipcMain, app, BrowserWindow } from "electron";
+import { ipcMain, app, BrowserWindow, shell } from "electron";
+import log from "electron-log";
export function registerAppListeners(mainWindow: BrowserWindow): void {
// Get app version
@@ -10,4 +11,28 @@ export function registerAppListeners(mainWindow: BrowserWindow): void {
ipcMain.handle("app:platform", () => {
return process.platform;
});
+
+ ipcMain.handle("app:open-external", async (event, url: string) => {
+ if (event.sender.id !== mainWindow.webContents.id) {
+ log.warn(
+ `Ignoring open-external from untrusted sender (id: ${event.sender.id})`,
+ );
+ throw new Error("Refusing external open from untrusted renderer");
+ }
+
+ let parsedUrl: URL;
+
+ try {
+ parsedUrl = new URL(url);
+ } catch {
+ throw new Error("Refusing to open invalid external URL");
+ }
+
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
+ throw new Error("Refusing to open non-HTTP external URL");
+ }
+
+ log.info("Renderer requested system browser open");
+ await shell.openExternal(parsedUrl.toString());
+ });
}
diff --git a/mcpjam-inspector/src/main.ts b/mcpjam-inspector/src/main.ts
index f13a4dac3..9820f6a2c 100644
--- a/mcpjam-inspector/src/main.ts
+++ b/mcpjam-inspector/src/main.ts
@@ -39,9 +39,68 @@ if (!app.isDefaultProtocolClient("mcpjam")) {
let mainWindow: BrowserWindow | null = null;
let server: any = null;
let serverPort: number = 0;
+let pendingProtocolUrl: string | null = null;
+let appBootstrapped = false;
const isDev = process.env.NODE_ENV === "development";
+function getServerUrl(): string {
+ return `http://127.0.0.1:${serverPort}`;
+}
+
+function getRendererBaseUrl(): string {
+ return isDev ? MAIN_WINDOW_VITE_DEV_SERVER_URL : getServerUrl();
+}
+
+function findOAuthCallbackUrl(args: string[]): string | undefined {
+ return args.find((arg) => arg.startsWith("mcpjam://oauth/callback"));
+}
+
+function isSafeExternalUrl(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+ return urlObj.protocol === "http:" || urlObj.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function isHostedAuthNavigation(url: string): boolean {
+ try {
+ const urlObj = new URL(url);
+ return (
+ (urlObj.protocol === "http:" || urlObj.protocol === "https:") &&
+ urlObj.pathname.endsWith("/user_management/authorize") &&
+ urlObj.searchParams.has("client_id") &&
+ urlObj.searchParams.has("redirect_uri") &&
+ urlObj.searchParams.get("response_type") === "code"
+ );
+ } catch {
+ return false;
+ }
+}
+
+function buildRendererCallbackUrl(
+ callbackUrl: URL,
+ baseUrl: string,
+): URL | null {
+ const flow = callbackUrl.searchParams.get("flow");
+
+ if (flow === "debug") {
+ return null;
+ }
+
+ const rendererPath = flow === "mcp" ? "/oauth/callback" : "/callback";
+ const rendererUrl = new URL(rendererPath, baseUrl);
+
+ for (const [key, value] of callbackUrl.searchParams.entries()) {
+ if (key === "flow") continue;
+ rendererUrl.searchParams.append(key, value);
+ }
+
+ return rendererUrl;
+}
+
async function startHonoServer(): Promise {
try {
const port = 6274;
@@ -96,6 +155,34 @@ function createMainWindow(serverUrl: string): BrowserWindow {
window.webContents.openDevTools();
}
+ const maybeOpenExternalNavigation = (
+ event: { preventDefault: () => void },
+ url: string,
+ isMainFrame: boolean,
+ ) => {
+ if (!isMainFrame || !isHostedAuthNavigation(url)) {
+ return;
+ }
+
+ log.info("Opening hosted auth in system browser");
+ event.preventDefault();
+ void shell.openExternal(url);
+ };
+
+ window.webContents.on(
+ "will-navigate",
+ (event, url, _isInPlace, isMainFrame) => {
+ maybeOpenExternalNavigation(event, url, isMainFrame);
+ },
+ );
+
+ window.webContents.on(
+ "will-redirect",
+ (event, url, _isInPlace, isMainFrame) => {
+ maybeOpenExternalNavigation(event, url, isMainFrame);
+ },
+ );
+
// Show window when ready
window.once("ready-to-show", () => {
window.show();
@@ -113,6 +200,64 @@ function createMainWindow(serverUrl: string): BrowserWindow {
return window;
}
+async function handleOAuthCallbackUrl(url: string): Promise {
+ if (!url.startsWith("mcpjam://oauth/callback")) {
+ return;
+ }
+
+ if (!appBootstrapped) {
+ pendingProtocolUrl = url;
+ return;
+ }
+
+ try {
+ log.info("OAuth callback received");
+
+ const parsed = new URL(url);
+ const callbackFlow = parsed.searchParams.get("flow");
+ const hadMainWindow = Boolean(mainWindow);
+
+ if (serverPort === 0) {
+ serverPort = await startHonoServer();
+ }
+
+ const baseUrl = getRendererBaseUrl();
+ const rendererCallbackUrl = buildRendererCallbackUrl(parsed, baseUrl);
+
+ if (!mainWindow) {
+ if (rendererCallbackUrl) {
+ mainWindow = createMainWindow(baseUrl);
+ mainWindow.loadURL(rendererCallbackUrl.toString());
+ } else {
+ const debugCallbackUrl = new URL("/oauth/callback/debug", baseUrl);
+ for (const [key, value] of parsed.searchParams.entries()) {
+ if (key === "flow") continue;
+ debugCallbackUrl.searchParams.append(key, value);
+ }
+ mainWindow = createMainWindow(baseUrl);
+ mainWindow.loadURL(debugCallbackUrl.toString());
+ }
+ } else if (rendererCallbackUrl) {
+ mainWindow.loadURL(rendererCallbackUrl.toString());
+ }
+
+ if (mainWindow?.webContents && callbackFlow === "debug" && hadMainWindow) {
+ mainWindow.webContents.send("oauth-callback", url);
+ } else if (
+ mainWindow?.webContents &&
+ callbackFlow !== "mcp" &&
+ callbackFlow !== "debug"
+ ) {
+ mainWindow.webContents.send("oauth-callback", url);
+ }
+
+ if (mainWindow?.isMinimized()) mainWindow.restore();
+ mainWindow?.focus();
+ } catch (error) {
+ log.error("Failed processing OAuth callback URL:", error);
+ }
+}
+
function createAppMenu(): void {
const isMac = process.platform === "darwin";
@@ -202,7 +347,7 @@ app.whenReady().then(async () => {
try {
// Start the embedded Hono server
serverPort = await startHonoServer();
- const serverUrl = `http://127.0.0.1:${serverPort}`;
+ const serverUrl = getServerUrl();
// Create the main window
createAppMenu();
@@ -214,6 +359,21 @@ app.whenReady().then(async () => {
// Setup auto-updater events to notify renderer when update is ready
setupAutoUpdaterEvents(mainWindow);
+ appBootstrapped = true;
+
+ if (pendingProtocolUrl) {
+ const protocolUrl = pendingProtocolUrl;
+ pendingProtocolUrl = null;
+ await handleOAuthCallbackUrl(protocolUrl);
+ }
+
+ if (process.platform !== "darwin") {
+ const protocolUrl = findOAuthCallbackUrl(process.argv);
+ if (protocolUrl) {
+ await handleOAuthCallbackUrl(protocolUrl);
+ }
+ }
+
log.info("MCPJam Electron app ready");
} catch (error) {
log.error("Failed to initialize app:", error);
@@ -238,14 +398,12 @@ app.on("activate", async () => {
// On macOS, re-create window when the dock icon is clicked
if (BrowserWindow.getAllWindows().length === 0) {
if (serverPort > 0) {
- const serverUrl = `http://127.0.0.1:${serverPort}`;
- mainWindow = createMainWindow(serverUrl);
+ mainWindow = createMainWindow(getServerUrl());
} else {
// Restart server if needed
try {
serverPort = await startHonoServer();
- const serverUrl = `http://127.0.0.1:${serverPort}`;
- mainWindow = createMainWindow(serverUrl);
+ mainWindow = createMainWindow(getServerUrl());
} catch (error) {
log.error("Failed to restart server:", error);
}
@@ -256,127 +414,50 @@ app.on("activate", async () => {
// Handle OAuth callback URLs
app.on("open-url", (event, url) => {
event.preventDefault();
- log.info("OAuth callback received:", url);
-
- if (!url.startsWith("mcpjam://oauth/callback")) {
- return;
- }
-
- try {
- const parsed = new URL(url);
- const code = parsed.searchParams.get("code") ?? "";
- const state = parsed.searchParams.get("state") ?? "";
-
- // Compute the base URL the renderer should load
- const baseUrl = isDev
- ? MAIN_WINDOW_VITE_DEV_SERVER_URL
- : `http://127.0.0.1:${serverPort}`;
-
- const callbackUrl = new URL("/callback", baseUrl);
- if (code) callbackUrl.searchParams.set("code", code);
- if (state) callbackUrl.searchParams.set("state", state);
-
- // Ensure a window exists, then load the callback route directly
- if (!mainWindow) {
- mainWindow = createMainWindow(baseUrl);
- }
- mainWindow.loadURL(callbackUrl.toString());
-
- // Still emit the event for any listeners
- if (mainWindow && mainWindow.webContents) {
- mainWindow.webContents.send("oauth-callback", url);
- }
-
- if (mainWindow) {
- if (mainWindow.isMinimized()) mainWindow.restore();
- mainWindow.focus();
- }
- } catch (e) {
- log.error("Failed processing OAuth callback URL:", e);
- }
+ void handleOAuthCallbackUrl(url);
});
// Security: Prevent new window creation, but allow OAuth popups
app.on("web-contents-created", (_, contents) => {
- contents.setWindowOpenHandler(({ url, features }) => {
+ contents.setWindowOpenHandler(({ url, frameName }) => {
try {
- const urlObj = new URL(url);
-
- // Allow OAuth authorization popups to be created within Electron
- // OAuth authorization URLs are typically external HTTPS URLs
- // Check if this looks like an OAuth flow (external HTTPS URL)
- const isOAuthFlow =
- urlObj.protocol === "https:" &&
- // Common OAuth authorization endpoint patterns
- (urlObj.pathname.includes("/oauth") ||
- urlObj.pathname.includes("/authorize") ||
- urlObj.pathname.includes("/auth") ||
- urlObj.searchParams.has("client_id") ||
- urlObj.searchParams.has("response_type"));
-
- if (isOAuthFlow) {
- // Parse window features to create popup window
- const width = features?.includes("width=")
- ? parseInt(features.match(/width=(\d+)/)?.[1] || "600")
- : 600;
- const height = features?.includes("height=")
- ? parseInt(features.match(/height=(\d+)/)?.[1] || "700")
- : 700;
-
- // Create a new BrowserWindow for OAuth popup
- const popup = new BrowserWindow({
- width,
- height,
- webPreferences: {
- nodeIntegration: false,
- contextIsolation: true,
- preload: path.join(__dirname, "preload.js"),
+ // The OAuth debugger popup explicitly names its window with the
+ // `oauth_authorization_` prefix so it can keep window.opener semantics.
+ if (frameName.startsWith("oauth_authorization_")) {
+ return {
+ action: "allow",
+ createWindow: (options) => {
+ const popup = new BrowserWindow({
+ ...options,
+ parent: mainWindow || undefined,
+ modal: false,
+ show: false,
+ webPreferences: {
+ ...options.webPreferences,
+ nodeIntegration: false,
+ contextIsolation: true,
+ preload: path.join(__dirname, "preload.js"),
+ },
+ });
+
+ popup.once("ready-to-show", () => {
+ popup.show();
+ });
+
+ return popup.webContents;
},
- parent: mainWindow || undefined,
- modal: false,
- show: false,
- });
-
- // Load the OAuth URL
- popup.loadURL(url);
-
- // Show window when ready
- popup.once("ready-to-show", () => {
- popup.show();
- });
-
- // Handle OAuth callback redirects
- popup.webContents.on("will-redirect", (event, navigationUrl) => {
- try {
- const redirectUrl = new URL(navigationUrl);
- // If redirecting to our callback URL, handle it
- if (
- redirectUrl.protocol === "mcpjam:" ||
- redirectUrl.pathname.includes("/callback") ||
- redirectUrl.pathname.includes("/oauth/callback")
- ) {
- // Let the redirect happen, the callback handler will process it
- // But we need to ensure the popup can communicate back
- }
- } catch (e) {
- // Invalid URL, ignore
- }
- });
-
- // Clean up when popup closes
- popup.on("closed", () => {
- // Popup closed, cleanup handled automatically
- });
-
- return { action: "allow" };
+ };
}
- // For all other URLs, open externally
- shell.openExternal(url);
+ if (isSafeExternalUrl(url)) {
+ void shell.openExternal(url);
+ } else {
+ log.warn("Refusing to open non-HTTP URL from window.open");
+ }
return { action: "deny" };
- } catch (e) {
- // If URL parsing fails, open externally as fallback
- shell.openExternal(url);
+ } catch (error) {
+ // Invalid URLs are denied to avoid passing unsafe schemes to the shell.
+ log.error("Failed handling window.open URL:", error);
return { action: "deny" };
}
});
@@ -395,8 +476,12 @@ const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
- app.on("second-instance", () => {
- // Someone tried to run a second instance, focus our window instead
+ app.on("second-instance", (_event, argv) => {
+ const protocolUrl = findOAuthCallbackUrl(argv);
+ if (protocolUrl) {
+ void handleOAuthCallbackUrl(protocolUrl);
+ }
+
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
diff --git a/mcpjam-inspector/src/preload.ts b/mcpjam-inspector/src/preload.ts
index 1a44c0e68..8fea0039a 100644
--- a/mcpjam-inspector/src/preload.ts
+++ b/mcpjam-inspector/src/preload.ts
@@ -12,6 +12,7 @@ interface ElectronAPI {
app: {
getVersion: () => Promise;
getPlatform: () => Promise;
+ openExternal: (url: string) => Promise;
};
// File operations
@@ -56,6 +57,7 @@ const electronAPI: ElectronAPI = {
app: {
getVersion: () => ipcRenderer.invoke("app:version"),
getPlatform: () => ipcRenderer.invoke("app:platform"),
+ openExternal: (url) => ipcRenderer.invoke("app:open-external", url),
},
files: {
diff --git a/mcpjam-inspector/vite.renderer.config.mts b/mcpjam-inspector/vite.renderer.config.mts
index aae128379..fdd6ee952 100644
--- a/mcpjam-inspector/vite.renderer.config.mts
+++ b/mcpjam-inspector/vite.renderer.config.mts
@@ -34,6 +34,13 @@ export default defineConfig(({ mode }) => {
target: "http://localhost:6274",
changeOrigin: true,
},
+ // Proxy WorkOS API calls during Electron local dev to avoid browser CORS
+ // issues and match the web client Vite config behavior.
+ "/user_management": {
+ target: "https://api.workos.com",
+ changeOrigin: true,
+ secure: true,
+ },
},
},
define: {