Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions mcpjam-inspector/client/src/components/ChatTabV2.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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 } =
Expand Down
44 changes: 44 additions & 0 deletions mcpjam-inspector/client/src/components/OAuthFlowTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,42 @@ export const OAuthFlowTab = ({
}
};

const handleElectronOAuthCallback = (event: Event) => {
const callbackUrl = (event as CustomEvent<string>).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");
Expand All @@ -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]);
Expand Down
6 changes: 3 additions & 3 deletions mcpjam-inspector/client/src/components/OrganizationsTab.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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<HTMLInputElement>(null);

Expand Down
4 changes: 2 additions & 2 deletions mcpjam-inspector/client/src/components/ProfileTab.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,9 @@ export function ServerDetailModal({
>
<Button
type="submit"
disabled={isDuplicateServerName || isSaving || !formState.hasChanges}
disabled={
isDuplicateServerName || isSaving || !formState.hasChanges
}
size="sm"
>
{isSaving ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ export function useServerForm(
}

// Derive local values used for both state initialization and snapshot
const serverType: "stdio" | "http" = server.config.command ? "stdio" : "http";
const serverType: "stdio" | "http" = server.config.command
? "stdio"
: "http";
const serverUrl = isHttpServer && config.url ? config.url.toString() : "";
const fullCommand = server.config.command
? [server.config.command, ...(server.config.args || [])]
Expand All @@ -117,7 +119,9 @@ export function useServerForm(
typeof config.requestInit.headers.Authorization === "string" &&
config.requestInit.headers.Authorization.startsWith("Bearer ");
const bearerTokenValue = hasBearer
? (config.requestInit!.headers as Record<string, string>).Authorization.replace("Bearer ", "")
? (
config.requestInit!.headers as Record<string, string>
).Authorization.replace("Bearer ", "")
: "";
const resolvedAuthType: "oauth" | "bearer" | "none" = hasOAuth
? "oauth"
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,35 @@ 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;
const top = window.screenY + (window.outerHeight - height) / 2;

// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,24 @@ import {
} from "@/lib/oauth/oauthUtils";
import { CheckCircle2, XCircle } from "lucide-react";

export 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<string | null>(
null,
);
const hasAttemptedSendRef = useRef(false);

useEffect(() => {
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -97,6 +130,13 @@ export default function OAuthDebugCallback() {
<p className="mt-4 text-xs text-muted-foreground">
Return to the OAuth Flow tab and paste the code to continue.
</p>
{window.isElectron && !window.opener && (
<p className="mt-3 text-xs text-muted-foreground">
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.
</p>
)}
</>
)}
</>
Expand All @@ -111,8 +151,23 @@ export default function OAuthDebugCallback() {
<div className="text-xs text-muted-foreground whitespace-pre-wrap">
{generateOAuthErrorDescription(callbackParams)}
</div>
{window.isElectron && !window.opener && (
<p className="mt-3 text-xs text-muted-foreground">
The debug session is no longer active in a popup window. Return
to MCPJam Inspector and start a new flow to retry.
</p>
)}
</>
)}
{!window.isElectron && returnToElectronUrl && (
<p className="mt-4 text-xs text-muted-foreground">
Returning to MCPJam Inspector. If nothing happens,{" "}
<a className="underline" href={returnToElectronUrl}>
click here
</a>
.
</p>
)}
</div>
</div>
);
Expand Down
Loading