From a15a6b883f003c6d5e82f33be376c39476b8bff6 Mon Sep 17 00:00:00 2001 From: Ignacio Jimenez Rocabado Date: Fri, 20 Mar 2026 22:44:01 -0700 Subject: [PATCH 01/13] Electron Sign in redirects to localhost --- mcpjam-inspector/client/src/App.tsx | 4 +- .../client/src/components/ChatTabV2.tsx | 4 +- .../client/src/components/OAuthFlowTab.tsx | 44 +++ .../src/components/OrganizationsTab.tsx | 6 +- .../client/src/components/ProfileTab.tsx | 4 +- .../src/components/auth/auth-upper-area.tsx | 4 +- .../src/components/hosted/SandboxChatPage.tsx | 4 +- .../oauth/OAuthAuthorizationModal.tsx | 20 +- .../components/oauth/OAuthDebugCallback.tsx | 63 +++- .../oauth/OAuthFlowProgressSimple.tsx | 60 +++- .../setting/AccountApiKeySection.tsx | 4 +- .../src/components/sidebar/sidebar-user.tsx | 4 +- .../ui-playground/PlaygroundMain.tsx | 4 +- .../client/src/hooks/use-server-state.ts | 103 +++++- .../client/src/hooks/useElectronHostedAuth.ts | 82 +++++ .../client/src/hooks/useElectronOAuth.ts | 15 +- .../src/lib/__tests__/guest-session.test.ts | 32 +- .../client/src/lib/oauth/constants.ts | 18 +- .../src/lib/oauth/debug-oauth-provider.ts | 2 - .../client/src/lib/oauth/mcp-oauth.ts | 16 +- .../client/src/lib/workos-config.ts | 56 +++ mcpjam-inspector/client/src/main.tsx | 59 +--- .../client/src/types/electron.d.ts | 1 + mcpjam-inspector/src/ipc/app/app-listeners.ts | 22 +- mcpjam-inspector/src/main.ts | 323 +++++++++++------- mcpjam-inspector/src/preload.ts | 2 + 26 files changed, 724 insertions(+), 232 deletions(-) create mode 100644 mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts create mode 100644 mcpjam-inspector/client/src/lib/workos-config.ts diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index e59b7fbb8..b106e3af1 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -1,6 +1,5 @@ import { useConvexAuth } from "convex/react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { useAuth } from "@workos-inc/authkit-react"; import { toast } from "sonner"; import { ServersTab } from "./components/ServersTab"; import { ToolsTab } from "./components/ToolsTab"; @@ -30,6 +29,7 @@ import { SidebarInset, SidebarProvider } from "./components/ui/sidebar"; import { useAppState } from "./hooks/use-app-state"; import { PreferencesStoreProvider } from "./stores/preferences/preferences-provider"; import { Toaster } from "./components/ui/sonner"; +import { useElectronHostedAuth } from "./hooks/useElectronHostedAuth"; import { useElectronOAuth } from "./hooks/useElectronOAuth"; import { useEnsureDbUser } from "./hooks/useEnsureDbUser"; import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react"; @@ -120,7 +120,7 @@ export default function App() { signIn, user: workOsUser, isLoading: isWorkOsLoading, - } = useAuth(); + } = useElectronHostedAuth(); const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); const [hostedOAuthHandling, setHostedOAuthHandling] = useState(() => HOSTED_MODE ? getHostedOAuthCallbackContext() !== null : false, diff --git a/mcpjam-inspector/client/src/components/ChatTabV2.tsx b/mcpjam-inspector/client/src/components/ChatTabV2.tsx index 8d71fc1ec..5e0bccfa6 100644 --- a/mcpjam-inspector/client/src/components/ChatTabV2.tsx +++ b/mcpjam-inspector/client/src/components/ChatTabV2.tsx @@ -1,6 +1,5 @@ import { FormEvent, useMemo, useState, useEffect, useCallback } from "react"; import { ArrowDown } from "lucide-react"; -import { useAuth } from "@workos-inc/authkit-react"; import { useConvexAuth } from "convex/react"; import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js"; import { ModelDefinition } from "@/shared/types"; @@ -20,6 +19,7 @@ import { MCPJamFreeModelsPrompt } from "@/components/chat-v2/mcpjam-free-models- import { usePostHog } from "posthog-js/react"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; import { ErrorBox } from "@/components/chat-v2/error"; +import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; 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"; @@ -100,7 +100,7 @@ export function ChatTabV2({ reasoningDisplayMode = "inline", onOAuthRequired, }: ChatTabProps) { - const { signUp } = useAuth(); + const { signUp } = useElectronHostedAuth(); const { isAuthenticated: isConvexAuthenticated } = useConvexAuth(); const appState = useSharedAppState(); const { isVisible: isJsonRpcPanelVisible, toggle: toggleJsonRpcPanel } = diff --git a/mcpjam-inspector/client/src/components/OAuthFlowTab.tsx b/mcpjam-inspector/client/src/components/OAuthFlowTab.tsx index 44fd57166..54265eb15 100644 --- a/mcpjam-inspector/client/src/components/OAuthFlowTab.tsx +++ b/mcpjam-inspector/client/src/components/OAuthFlowTab.tsx @@ -437,6 +437,42 @@ export const OAuthFlowTab = ({ } }; + 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) { + if (exchangeTimeoutRef.current) { + clearTimeout(exchangeTimeoutRef.current); + exchangeTimeoutRef.current = null; + } + + updateOAuthFlowState({ + error: errorDescription ?? error, + }); + return; + } + + const code = parsed.searchParams.get("code"); + const state = parsed.searchParams.get("state"); + if (code) { + processOAuthCallback(code, state); + } + } catch (error) { + console.error("Failed to process Electron OAuth callback:", error); + } + }; + let channel: BroadcastChannel | null = null; try { channel = new BroadcastChannel("oauth_callback_channel"); @@ -450,8 +486,16 @@ export const OAuthFlowTab = ({ } window.addEventListener("message", handleMessage); + window.addEventListener( + "electron-oauth-callback", + handleElectronOAuthCallback as EventListener, + ); return () => { window.removeEventListener("message", handleMessage); + window.removeEventListener( + "electron-oauth-callback", + handleElectronOAuthCallback as EventListener, + ); channel?.close(); }; }, [oauthStateMachine, updateOAuthFlowState]); diff --git a/mcpjam-inspector/client/src/components/OrganizationsTab.tsx b/mcpjam-inspector/client/src/components/OrganizationsTab.tsx index b8895f12e..4fce0f5fe 100644 --- a/mcpjam-inspector/client/src/components/OrganizationsTab.tsx +++ b/mcpjam-inspector/client/src/components/OrganizationsTab.tsx @@ -1,6 +1,5 @@ import { useState, useRef } from "react"; import { useConvexAuth } from "convex/react"; -import { useAuth } from "@workos-inc/authkit-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { EditableText } from "@/components/ui/editable-text"; @@ -30,6 +29,7 @@ import { import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth"; import { Organization, OrganizationMember, @@ -50,7 +50,7 @@ interface OrganizationsTabProps { } export function OrganizationsTab({ organizationId }: OrganizationsTabProps) { - const { user, signIn } = useAuth(); + const { user, signIn } = useElectronHostedAuth(); const { isAuthenticated } = useConvexAuth(); const { sortedOrganizations, isLoading } = useOrganizationQueries({ @@ -136,7 +136,7 @@ interface OrganizationPageProps { function OrganizationPage({ organization }: OrganizationPageProps) { const { isAuthenticated } = useConvexAuth(); - const { user } = useAuth(); + const { user } = useElectronHostedAuth(); const currentUserEmail = user?.email; const fileInputRef = useRef(null); diff --git a/mcpjam-inspector/client/src/components/ProfileTab.tsx b/mcpjam-inspector/client/src/components/ProfileTab.tsx index 30984e15d..4987d4d2d 100644 --- a/mcpjam-inspector/client/src/components/ProfileTab.tsx +++ b/mcpjam-inspector/client/src/components/ProfileTab.tsx @@ -1,15 +1,15 @@ import { useRef, useState } from "react"; -import { useAuth } from "@workos-inc/authkit-react"; import { useAction, useMutation, useQuery } from "convex/react"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { EditableText } from "@/components/ui/editable-text"; import { getInitials } from "@/lib/utils"; import { Camera, Loader2 } from "lucide-react"; +import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth"; import { useProfilePicture } from "@/hooks/useProfilePicture"; export function ProfileTab() { - const { user, signIn } = useAuth(); + const { user, signIn } = useElectronHostedAuth(); const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); diff --git a/mcpjam-inspector/client/src/components/auth/auth-upper-area.tsx b/mcpjam-inspector/client/src/components/auth/auth-upper-area.tsx index 75e6d9757..d692bc066 100644 --- a/mcpjam-inspector/client/src/components/auth/auth-upper-area.tsx +++ b/mcpjam-inspector/client/src/components/auth/auth-upper-area.tsx @@ -1,4 +1,3 @@ -import { useAuth } from "@workos-inc/authkit-react"; import { useConvexAuth } from "convex/react"; import { usePostHog } from "posthog-js/react"; import { Button } from "@/components/ui/button"; @@ -10,6 +9,7 @@ import { } from "@/components/ActiveServerSelector"; import { NotificationBell } from "@/components/notifications/NotificationBell"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; +import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth"; interface AuthUpperAreaProps { activeServerSelectorProps?: ActiveServerSelectorProps; @@ -18,7 +18,7 @@ interface AuthUpperAreaProps { export function AuthUpperArea({ activeServerSelectorProps, }: AuthUpperAreaProps) { - const { user, signIn, signUp } = useAuth(); + const { user, signIn, signUp } = useElectronHostedAuth(); const { isLoading } = useConvexAuth(); const posthog = usePostHog(); diff --git a/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx b/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx index 8cca1fa2a..815cf91d1 100644 --- a/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx +++ b/mcpjam-inspector/client/src/components/hosted/SandboxChatPage.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { useAuth } from "@workos-inc/authkit-react"; import { useConvexAuth } from "convex/react"; import { Loader2, Link2Off, ShieldX } from "lucide-react"; import { toast } from "sonner"; @@ -28,6 +27,7 @@ import type { HostedOAuthRequiredDetails } from "@/lib/hosted-oauth-required"; import { slugify } from "@/lib/shared-server-session"; import { SandboxHostStyleProvider } from "@/contexts/sandbox-host-style-context"; import { getSandboxShellStyle } from "@/lib/sandbox-host-style"; +import { useElectronHostedAuth } from "@/hooks/useElectronHostedAuth"; interface SandboxChatPageProps { pathToken?: string | null; @@ -248,7 +248,7 @@ export function SandboxChatPage({ pathToken, onExitSandboxChat, }: SandboxChatPageProps) { - const { getAccessToken, signIn } = useAuth(); + const { getAccessToken, signIn } = useElectronHostedAuth(); const { isAuthenticated, isLoading: isAuthLoading } = useConvexAuth(); const themeMode = usePreferencesStore((s) => s.themeMode); diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx index a94b050ee..1f1a68be6 100644 --- a/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx +++ b/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx @@ -64,6 +64,25 @@ export const OAuthAuthorizationModal = ({ if (open && !hasOpenedRef.current) { hasOpenedRef.current = true; + if (window.isElectron && window.electronAPI?.app?.openExternal) { + let cancelled = false; + + void window.electronAPI.app + .openExternal(authorizationUrl) + .catch((error) => { + console.error("[OAuth Popup] Failed to open system browser:", error); + }) + .finally(() => { + if (cancelled) return; + onOpenChange(false); + hasOpenedRef.current = false; + }); + + return () => { + cancelled = true; + }; + } + const width = 600; const height = 700; const left = window.screenX + (window.outerWidth - width) / 2; @@ -71,7 +90,6 @@ export const OAuthAuthorizationModal = ({ // Use unique window name each time to prevent reusing old popup with stale auth code const uniqueWindowName = `oauth_authorization_${Date.now()}`; - console.log("authorizationUrl", authorizationUrl); popupRef.current = window.open( authorizationUrl, uniqueWindowName, diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx index 849a6fed7..16fb83e73 100644 --- a/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx +++ b/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx @@ -5,9 +5,24 @@ import { } from "@/lib/oauth/oauthUtils"; import { CheckCircle2, XCircle } from "lucide-react"; +function buildElectronDebugCallbackUrl(): string { + const callbackUrl = new URL("mcpjam://oauth/callback"); + callbackUrl.searchParams.set("flow", "debug"); + + const params = new URLSearchParams(window.location.search); + for (const [key, value] of params.entries()) { + callbackUrl.searchParams.append(key, value); + } + + return callbackUrl.toString(); +} + export default function OAuthDebugCallback() { const callbackParams = parseOAuthCallbackParams(window.location.search); const [codeSent, setCodeSent] = useState(false); + const [returnToElectronUrl, setReturnToElectronUrl] = useState( + 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,23 @@ 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 && ( +

+ Returning to MCPJam Inspector. If nothing happens,{" "} + + 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/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 6db79cc50..d02034bf5 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(); 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/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 98ccf6ece..a864438b9 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -74,6 +74,60 @@ function saveOAuthConfigToLocalStorage(formData: ServerFormData): void { } } +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; + } + + const pendingServerName = localStorage.getItem("mcp-oauth-pending")?.trim(); + if (pendingServerName) { + 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); + }); +} + +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 +464,40 @@ 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 +518,7 @@ export function useServerState({ }); try { - const connectionResult = await testConnection( + const connectionResult = await testConnectionAfterOAuth( result.serverConfig, serverName, ); @@ -504,7 +592,13 @@ export function useServerState({ } } }, - [dispatch, failPendingOAuthConnection, logger, storeInitInfo], + [ + dispatch, + failPendingOAuthConnection, + logger, + storeInitInfo, + testConnectionAfterOAuth, + ], ); useEffect(() => { @@ -527,9 +621,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..4180ee1ed --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts @@ -0,0 +1,82 @@ +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"; + +export function useElectronHostedAuth() { + const auth = useAuth(); + const defaultSignIn = auth.signIn; + const defaultSignUp = auth.signUp; + + 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], + ); + + return { + ...auth, + signIn, + signUp, + }; +} 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..dfb3f84aa 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,26 @@ 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/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..68bceb8e7 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -21,6 +21,14 @@ interface StoredOAuthDiscoveryState { discoveryState: OAuthDiscoveryState; } +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}`; } @@ -142,7 +150,7 @@ 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; } @@ -260,6 +268,12 @@ export class MCPOAuthProvider implements OAuthClientProvider { if (window.location.hash) { localStorage.setItem("mcp-oauth-return-hash", window.location.hash); } + + if (window.isElectron && window.electronAPI?.app?.openExternal) { + await window.electronAPI.app.openExternal(authorizationUrl.toString()); + return; + } + window.location.href = authorizationUrl.toString(); } 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..1b452f1d6 --- /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"; + + 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`; +} + +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..d60642300 100644 --- a/mcpjam-inspector/client/src/main.tsx +++ b/mcpjam-inspector/client/src/main.tsx @@ -11,6 +11,12 @@ 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"; // Initialize Sentry before React mounts initSentry(); @@ -37,32 +43,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,29 +59,7 @@ 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); 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..88a322661 100644 --- a/mcpjam-inspector/src/ipc/app/app-listeners.ts +++ b/mcpjam-inspector/src/ipc/app/app-listeners.ts @@ -1,6 +1,7 @@ -import { ipcMain, app, BrowserWindow } from "electron"; +import { ipcMain, app, BrowserWindow, shell } from "electron"; +import log from "electron-log"; -export function registerAppListeners(mainWindow: BrowserWindow): void { +export function registerAppListeners(_mainWindow: BrowserWindow): void { // Get app version ipcMain.handle("app:version", () => { return app.getVersion(); @@ -10,4 +11,21 @@ export function registerAppListeners(mainWindow: BrowserWindow): void { ipcMain.handle("app:platform", () => { return process.platform; }); + + ipcMain.handle("app:open-external", async (_event, url: string) => { + 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: { From 6cebca4aa9490444af1f827d93a2b0648e051004 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:49:07 +0000 Subject: [PATCH 02/13] style: auto-fix prettier formatting --- .../src/components/oauth/OAuthAuthorizationModal.tsx | 5 ++++- mcpjam-inspector/client/src/hooks/use-server-state.ts | 11 +++++++---- .../client/src/hooks/useElectronHostedAuth.ts | 6 ++++-- .../client/src/lib/__tests__/guest-session.test.ts | 4 +++- mcpjam-inspector/client/src/lib/workos-config.ts | 9 +++------ 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx index 1f1a68be6..d33fbfdf9 100644 --- a/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx +++ b/mcpjam-inspector/client/src/components/oauth/OAuthAuthorizationModal.tsx @@ -70,7 +70,10 @@ export const OAuthAuthorizationModal = ({ void window.electronAPI.app .openExternal(authorizationUrl) .catch((error) => { - console.error("[OAuth Popup] Failed to open system browser:", error); + console.error( + "[OAuth Popup] Failed to open system browser:", + error, + ); }) .finally(() => { if (cancelled) return; diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index a864438b9..5c4d47325 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -475,10 +475,13 @@ export function useServerState({ return firstResult; } - logger.warn("Retrying OAuth connection after transient transport error", { - serverName, - error: firstResult.error, - }); + 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"; diff --git a/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts index 4180ee1ed..c13e0bb67 100644 --- a/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts +++ b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts @@ -65,12 +65,14 @@ export function useElectronHostedAuth() { ); const signIn = useCallback( - (opts?: Parameters[0]) => openHostedAuth("signIn", opts), + (opts?: Parameters[0]) => + openHostedAuth("signIn", opts), [openHostedAuth], ); const signUp = useCallback( - (opts?: Parameters[0]) => openHostedAuth("signUp", opts), + (opts?: Parameters[0]) => + openHostedAuth("signUp", opts), [openHostedAuth], ); 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 dfb3f84aa..dc64de004 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/guest-session.test.ts @@ -251,7 +251,9 @@ describe("guest-session module", () => { expiresAt: now.getTime() + 5 * 60 * 1000 + 1, // 5 min + 1ms buffer }; - vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify(session)); + vi.mocked(localStorage.getItem).mockReturnValue( + JSON.stringify(session), + ); const result = await guestSession.getOrCreateGuestSession(); diff --git a/mcpjam-inspector/client/src/lib/workos-config.ts b/mcpjam-inspector/client/src/lib/workos-config.ts index 1b452f1d6..a30145538 100644 --- a/mcpjam-inspector/client/src/lib/workos-config.ts +++ b/mcpjam-inspector/client/src/lib/workos-config.ts @@ -12,9 +12,7 @@ export function getWorkosDevMode(): boolean { if (explicit === "false") return false; if (import.meta.env.DEV) return true; - return ( - location.hostname === "localhost" || location.hostname === "127.0.0.1" - ); + return location.hostname === "localhost" || location.hostname === "127.0.0.1"; } export function getWorkosRedirectUri(): string { @@ -41,9 +39,8 @@ export function getWorkosClientOptions(): WorkosClientOptions { if (typeof window === "undefined") return {}; const disableProxy = - (import.meta.env.VITE_WORKOS_DISABLE_LOCAL_PROXY as - | string - | undefined) === "true"; + (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; From 5d8c957b9d14354febfc2b4a29cf139dfa0c9705 Mon Sep 17 00:00:00 2001 From: Ignacio Jimenez Rocabado Date: Fri, 20 Mar 2026 23:10:49 -0700 Subject: [PATCH 03/13] tests --- .../components/oauth/OAuthDebugCallback.tsx | 2 +- .../__tests__/OAuthDebugCallback.test.tsx | 21 +++ .../hooks/__tests__/use-server-state.test.tsx | 132 ++++++++++++++++-- .../__tests__/useElectronHostedAuth.test.tsx | 121 ++++++++++++++++ .../client/src/hooks/use-server-state.ts | 6 +- .../src/lib/__tests__/workos-config.test.ts | 43 ++++++ .../client/src/lib/workos-config.ts | 5 +- 7 files changed, 315 insertions(+), 15 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/oauth/__tests__/OAuthDebugCallback.test.tsx create mode 100644 mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx create mode 100644 mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts diff --git a/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx b/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx index 16fb83e73..247e439d2 100644 --- a/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx +++ b/mcpjam-inspector/client/src/components/oauth/OAuthDebugCallback.tsx @@ -5,7 +5,7 @@ import { } from "@/lib/oauth/oauthUtils"; import { CheckCircle2, XCircle } from "lucide-react"; -function buildElectronDebugCallbackUrl(): string { +export function buildElectronDebugCallbackUrl(): string { const callbackUrl = new URL("mcpjam://oauth/callback"); callbackUrl.searchParams.set("flow", "debug"); 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/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index e88223f92..718fe6167 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,7 +33,9 @@ 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", () => ({ @@ -32,7 +44,7 @@ vi.mock("@/state/oauth-orchestrator", () => ({ vi.mock("@/lib/oauth/mcp-oauth", () => ({ handleOAuthCallback: handleOAuthCallbackMock, - getStoredTokens: vi.fn(), + getStoredTokens: getStoredTokensMock, clearOAuthData: vi.fn(), initiateOAuth: vi.fn(), })); @@ -133,6 +145,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 +211,96 @@ describe("useServerState OAuth callback failures", () => { ); expect(localStorage.getItem("mcp-oauth-pending")).toBeNull(); }); + + it("bounces browser OAuth callbacks back into Electron when no pending browser state exists", async () => { + window.isElectron = false; + window.history.replaceState( + {}, + "", + "/oauth/callback?code=test-code&state=test-state", + ); + + expect(buildElectronMcpCallbackUrl()).toBe( + "mcpjam://oauth/callback?flow=mcp&code=test-code&state=test-state", + ); + }); + + 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..d84ca707c --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx @@ -0,0 +1,121 @@ +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 openExternal = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + + window.isElectron = false; + window.electronAPI = { + app: { + openExternal, + }, + } as any; + + useAuthMock.mockReturnValue({ + signIn: defaultSignIn, + signUp: defaultSignUp, + signOut: vi.fn(), + 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(); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 5c4d47325..ea6f99e1f 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -74,7 +74,7 @@ function saveOAuthConfigToLocalStorage(formData: ServerFormData): void { } } -function buildElectronMcpCallbackUrl(): string | null { +export function buildElectronMcpCallbackUrl(): string | null { if (window.isElectron || window.location.pathname !== "/oauth/callback") { return null; } @@ -107,7 +107,9 @@ function delay(ms: number): Promise { }); } -function shouldRetryOAuthConnectionFailure(errorMessage?: string): boolean { +export function shouldRetryOAuthConnectionFailure( + errorMessage?: string, +): boolean { if (!errorMessage) { return false; } 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..539eedc2c --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts @@ -0,0 +1,43 @@ +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/workos-config.ts b/mcpjam-inspector/client/src/lib/workos-config.ts index a30145538..abc42cf09 100644 --- a/mcpjam-inspector/client/src/lib/workos-config.ts +++ b/mcpjam-inspector/client/src/lib/workos-config.ts @@ -20,12 +20,15 @@ export function getWorkosRedirectUri(): string { (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; - if ((window as any)?.isElectron) return "mcpjam://oauth/callback"; return `${window.location.origin}/callback`; } From 719b63e84c5422547d9564a37d00724ef18f0db8 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Sat, 21 Mar 2026 06:12:38 +0000 Subject: [PATCH 04/13] style: auto-fix prettier formatting --- .../client/src/hooks/__tests__/use-server-state.test.tsx | 7 +++++-- .../client/src/lib/__tests__/workos-config.test.ts | 5 +---- 2 files changed, 6 insertions(+), 6 deletions(-) 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 718fe6167..23344c16f 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 @@ -228,7 +228,7 @@ describe("useServerState OAuth callback failures", () => { it("detects retryable transport errors after OAuth", () => { expect( shouldRetryOAuthConnectionFailure( - 'Streamable HTTP error: Request timed out. SSE error: SSE error: Non-200 status code (404).', + "Streamable HTTP error: Request timed out. SSE error: SSE error: Non-200 status code (404).", ), ).toBe(true); expect( @@ -242,7 +242,10 @@ describe("useServerState OAuth callback failures", () => { vi.useFakeTimers(); localStorage.setItem("mcp-oauth-pending", "demo-server"); - localStorage.setItem("mcp-serverUrl-demo-server", "https://example.com/mcp"); + 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"); diff --git a/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts b/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts index 539eedc2c..757db9bbf 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/workos-config.test.ts @@ -1,8 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - getWorkosDevMode, - getWorkosRedirectUri, -} from "../workos-config"; +import { getWorkosDevMode, getWorkosRedirectUri } from "../workos-config"; describe("workos-config", () => { beforeEach(() => { From 23cc3ad1c00c9f23675b1c355d0a55157f5d4b58 Mon Sep 17 00:00:00 2001 From: Ignacio Jimenez Rocabado Date: Sat, 21 Mar 2026 13:04:51 -0700 Subject: [PATCH 05/13] didn't test og login, just server login and oauth debugger --- .../src/components/sidebar/sidebar-user.tsx | 12 ++-- .../__tests__/useElectronHostedAuth.test.tsx | 46 +++++++++++- .../client/src/hooks/useElectronHostedAuth.ts | 61 ++++++++++++++++ mcpjam-inspector/client/src/main.tsx | 70 +++++++++++++++---- mcpjam-inspector/vite.renderer.config.mts | 7 ++ 5 files changed, 174 insertions(+), 22 deletions(-) diff --git a/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx b/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx index d02034bf5..8d55ce3f2 100644 --- a/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx +++ b/mcpjam-inspector/client/src/components/sidebar/sidebar-user.tsx @@ -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/hooks/__tests__/useElectronHostedAuth.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx index d84ca707c..afdff240a 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/useElectronHostedAuth.test.tsx @@ -37,6 +37,7 @@ import { useElectronHostedAuth } from "../useElectronHostedAuth"; describe("useElectronHostedAuth", () => { const defaultSignIn = vi.fn(); const defaultSignUp = vi.fn(); + const defaultSignOut = vi.fn(); const openExternal = vi.fn(); beforeEach(() => { @@ -52,7 +53,7 @@ describe("useElectronHostedAuth", () => { useAuthMock.mockReturnValue({ signIn: defaultSignIn, signUp: defaultSignUp, - signOut: vi.fn(), + signOut: defaultSignOut, user: null, isLoading: false, }); @@ -118,4 +119,47 @@ describe("useElectronHostedAuth", () => { 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/useElectronHostedAuth.ts b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts index c13e0bb67..fb338606e 100644 --- a/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts +++ b/mcpjam-inspector/client/src/hooks/useElectronHostedAuth.ts @@ -8,10 +8,32 @@ import { 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 ( @@ -76,9 +98,48 @@ export function useElectronHostedAuth() { [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/main.tsx b/mcpjam-inspector/client/src/main.tsx index d60642300..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"; @@ -18,6 +18,53 @@ import { 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(); @@ -63,19 +110,6 @@ if (isInIframe) { const convex = new ConvexReactClient(convexUrl); - const Providers = ( - - - - - - ); - // Async bootstrap to initialize session token before rendering async function bootstrap() { const root = createRoot(document.getElementById("root")!); @@ -146,7 +180,13 @@ if (isInIframe) { root.render( - {Providers} + , ); 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: { From 37a745c0c2fa8146123d8e7a95363ed11492b679 Mon Sep 17 00:00:00 2001 From: Ignacio Jimenez Rocabado Date: Sat, 21 Mar 2026 19:05:01 -0700 Subject: [PATCH 06/13] Harden Electron OAuth external browser flow --- .../src/lib/oauth/__tests__/mcp-oauth.test.ts | 40 +++++++++++++++++++ .../client/src/lib/oauth/mcp-oauth.ts | 13 ++++-- mcpjam-inspector/src/ipc/app/app-listeners.ts | 11 ++++- 3 files changed, 59 insertions(+), 5 deletions(-) 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..5f94bfc03 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,42 @@ describe("mcp-oauth", () => { }); describe("persisted discovery state", () => { + 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 assignSpy = vi + .spyOn(window.location, "assign") + .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(assignSpy).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/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 68bceb8e7..ac7630f7e 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -270,11 +270,18 @@ export class MCPOAuthProvider implements OAuthClientProvider { } if (window.isElectron && window.electronAPI?.app?.openExternal) { - await window.electronAPI.app.openExternal(authorizationUrl.toString()); - return; + 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, + ); + } } - window.location.href = authorizationUrl.toString(); + window.location.assign(authorizationUrl.toString()); } async saveCodeVerifier(codeVerifier: string) { diff --git a/mcpjam-inspector/src/ipc/app/app-listeners.ts b/mcpjam-inspector/src/ipc/app/app-listeners.ts index 88a322661..18979a061 100644 --- a/mcpjam-inspector/src/ipc/app/app-listeners.ts +++ b/mcpjam-inspector/src/ipc/app/app-listeners.ts @@ -1,7 +1,7 @@ import { ipcMain, app, BrowserWindow, shell } from "electron"; import log from "electron-log"; -export function registerAppListeners(_mainWindow: BrowserWindow): void { +export function registerAppListeners(mainWindow: BrowserWindow): void { // Get app version ipcMain.handle("app:version", () => { return app.getVersion(); @@ -12,7 +12,14 @@ export function registerAppListeners(_mainWindow: BrowserWindow): void { return process.platform; }); - ipcMain.handle("app:open-external", async (_event, url: string) => { + 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 { From a0a29b5d6259b1c9b4f93f77e7313704dfe6eb40 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Sun, 22 Mar 2026 03:17:09 +0000 Subject: [PATCH 07/13] style: auto-fix prettier formatting --- .../src/components/connection/ServerDetailModal.tsx | 4 +++- .../src/components/connection/hooks/use-server-form.ts | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx index 9d514a7e3..9a7149993 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx @@ -340,7 +340,9 @@ export function ServerDetailModal({ >