diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index e8f9794f8..1770e7187 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -500,6 +500,7 @@ export default function App() { appState, isLoading, isLoadingRemoteWorkspaces, + isWorkspaceBootstrapLoading, workspaceServers, connectedOrConnectingServerConfigs, selectedMCPConfig, @@ -1296,6 +1297,11 @@ export default function App() { !isOAuthCallback && billingEntitlementsUiEnabled !== false && pendingCheckoutIntent !== null; + const shouldShowWorkspaceBootstrapOverlay = + !shouldShowBillingHandoffOverlay && + !isHostedChatRoute && + !isOAuthCallback && + isWorkspaceBootstrapLoading; if (shouldHoldHostedDefaultRouteForAuth) { return ; @@ -1713,13 +1719,22 @@ export default function App() {
) : null} + {shouldShowWorkspaceBootstrapOverlay ? ( + + ) : null} ); diff --git a/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx b/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx index f60d5ab88..4fa2c2b4d 100644 --- a/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx +++ b/mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx @@ -50,6 +50,7 @@ const { }, isLoading: false, isLoadingRemoteWorkspaces: false, + isWorkspaceBootstrapLoading: false, workspaceServers: {}, connectedOrConnectingServerConfigs: {}, selectedMCPConfig: null, @@ -313,7 +314,12 @@ vi.mock("../components/CompletingSignInLoading", () => ({ default: () =>
, })); vi.mock("../components/LoadingScreen", () => ({ - default: () =>
, + default: ({ + testId, + }: { + overlay?: boolean; + testId?: string; + }) =>
, })); vi.mock("../components/Header", () => ({ Header: () =>
, @@ -1302,6 +1308,44 @@ describe("App hosted OAuth callback handling", () => { expect(screen.queryByText("Servers Tab")).not.toBeInTheDocument(); }); + it("keeps the shell hidden behind the workspace bootstrap overlay until import finishes", async () => { + clearHostedOAuthPendingState(); + clearSandboxSession(); + window.history.replaceState({}, "", "/#servers"); + mockHandleOAuthCallback.mockReset(); + + let bootstrapLoading = true; + mockUseAppState.mockImplementation(() => ({ + ...createAppStateMock(), + isWorkspaceBootstrapLoading: bootstrapLoading, + })); + + const { rerender } = render(); + + await waitFor(() => { + expect( + screen.getByTestId("workspace-bootstrap-loading-overlay"), + ).toBeInTheDocument(); + expect(screen.getByTestId("app-shell-container")).toHaveAttribute( + "aria-hidden", + "true", + ); + }); + + bootstrapLoading = false; + rerender(); + + await waitFor(() => { + expect( + screen.queryByTestId("workspace-bootstrap-loading-overlay"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Servers Tab")).toBeInTheDocument(); + expect(screen.getByTestId("app-shell-container")).not.toHaveAttribute( + "aria-hidden", + ); + }); + }); + it("does not auto-route signed-in users into App Builder once startup is ready", async () => { clearHostedOAuthPendingState(); clearSandboxSession(); diff --git a/mcpjam-inspector/client/src/components/LoadingScreen.tsx b/mcpjam-inspector/client/src/components/LoadingScreen.tsx index a768cbed3..07a892d41 100644 --- a/mcpjam-inspector/client/src/components/LoadingScreen.tsx +++ b/mcpjam-inspector/client/src/components/LoadingScreen.tsx @@ -1,6 +1,19 @@ -export default function LoadingScreen() { +export default function LoadingScreen({ + overlay = false, + testId = "loading-screen", +}: { + overlay?: boolean; + testId?: string; +}) { return ( -
+
diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 74aa23d9b..be4affbee 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -714,7 +714,6 @@ export function ServersTab({ }, [onUpdate, workspaceServers], ); - useEffect(() => { if (!detailModalState.isOpen || detailModalLiveServer == null) { return; 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 c6cbf6b39..11885900d 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 @@ -15,6 +15,9 @@ const { clearOAuthDataMock, testConnectionMock, mockConvexQuery, + createServerMutationMock, + updateServerMutationMock, + deleteServerMutationMock, } = vi.hoisted(() => ({ toastError: vi.fn(), toastSuccess: vi.fn(), @@ -24,6 +27,9 @@ const { clearOAuthDataMock: vi.fn(), testConnectionMock: vi.fn(), mockConvexQuery: vi.fn(), + createServerMutationMock: vi.fn(), + updateServerMutationMock: vi.fn(), + deleteServerMutationMock: vi.fn(), })); vi.mock("sonner", () => ({ @@ -78,9 +84,9 @@ vi.mock("@/stores/ui-playground-store", () => ({ vi.mock("../useWorkspaces", () => ({ useServerMutations: () => ({ - createServer: vi.fn(), - updateServer: vi.fn(), - deleteServer: vi.fn(), + createServer: createServerMutationMock, + updateServer: updateServerMutationMock, + deleteServer: deleteServerMutationMock, }), })); @@ -139,19 +145,29 @@ function createAppState(options?: { function renderUseServerState( dispatch: (action: AppAction) => void, appState = createAppState(), + options?: { + isAuthenticated?: boolean; + isAuthLoading?: boolean; + isLoadingWorkspaces?: boolean; + useLocalFallback?: boolean; + effectiveWorkspaces?: AppState["workspaces"]; + effectiveActiveWorkspaceId?: string; + activeWorkspaceServersFlat?: any[]; + }, ) { return renderHook(() => useServerState({ appState, dispatch, isLoading: false, - isAuthenticated: false, - isAuthLoading: false, - isLoadingWorkspaces: false, - useLocalFallback: true, - effectiveWorkspaces: appState.workspaces, - effectiveActiveWorkspaceId: appState.activeWorkspaceId, - activeWorkspaceServersFlat: undefined, + isAuthenticated: options?.isAuthenticated ?? false, + isAuthLoading: options?.isAuthLoading ?? false, + isLoadingWorkspaces: options?.isLoadingWorkspaces ?? false, + useLocalFallback: options?.useLocalFallback ?? true, + effectiveWorkspaces: options?.effectiveWorkspaces ?? appState.workspaces, + effectiveActiveWorkspaceId: + options?.effectiveActiveWorkspaceId ?? appState.activeWorkspaceId, + activeWorkspaceServersFlat: options?.activeWorkspaceServersFlat, logger: { info: vi.fn(), warn: vi.fn(), @@ -189,6 +205,9 @@ describe("useServerState OAuth callback failures", () => { }); initiateOAuthMock.mockResolvedValue({ success: true }); mockConvexQuery.mockResolvedValue(null); + createServerMutationMock.mockResolvedValue("remote-server-id"); + updateServerMutationMock.mockResolvedValue(undefined); + deleteServerMutationMock.mockResolvedValue(undefined); }); it("marks the pending server as failed when authorization is denied", async () => { @@ -392,6 +411,93 @@ describe("useServerState OAuth callback failures", () => { }); }); + it("preserves Authorization headers when creating a signed-in server config", async () => { + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch, createAppState(), { + isAuthenticated: true, + useLocalFallback: false, + effectiveActiveWorkspaceId: "ws_1", + activeWorkspaceServersFlat: [], + }); + + await act(async () => { + await result.current.saveServerConfigWithoutConnecting({ + name: "auth-server", + type: "http", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }); + }); + + expect(createServerMutationMock).toHaveBeenCalledWith({ + workspaceId: "ws_1", + name: "auth-server", + enabled: false, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + timeout: undefined, + useOAuth: false, + oauthScopes: undefined, + clientId: undefined, + }); + }); + + it("preserves Authorization headers when updating a signed-in server config", async () => { + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch, createAppState(), { + isAuthenticated: true, + useLocalFallback: false, + effectiveActiveWorkspaceId: "ws_1", + activeWorkspaceServersFlat: [ + { + _id: "srv_1", + workspaceId: "ws_1", + name: "demo-server", + }, + ], + }); + + await act(async () => { + await result.current.saveServerConfigWithoutConnecting({ + name: "demo-server", + type: "http", + url: "https://example.com/mcp", + useOAuth: true, + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }); + }); + + expect(updateServerMutationMock).toHaveBeenCalledWith({ + serverId: "srv_1", + name: "demo-server", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + timeout: undefined, + useOAuth: true, + oauthScopes: undefined, + clientId: undefined, + }); + }); + it("fails registry OAuth initiation when the dedicated OAuth config query fails", async () => { mockConvexQuery.mockRejectedValueOnce(new Error("registry lookup failed")); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index 676b4497f..d7e3ecbac 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx @@ -7,21 +7,27 @@ import { useClientConfigStore } from "@/stores/client-config-store"; import type { WorkspaceClientConfig } from "@/lib/client-config"; const { + bootstrapGuestServerImportMock, createWorkspaceMock, ensureDefaultWorkspaceMock, updateClientConfigMock, updateWorkspaceMock, deleteWorkspaceMock, + workspaceServersState, workspaceQueryState, organizationBillingStatusState, useOrganizationBillingStatusMock, - serializeServersForSharingMock, } = vi.hoisted(() => ({ + bootstrapGuestServerImportMock: vi.fn(), createWorkspaceMock: vi.fn(), ensureDefaultWorkspaceMock: vi.fn(), updateClientConfigMock: vi.fn(), updateWorkspaceMock: vi.fn(), deleteWorkspaceMock: vi.fn(), + workspaceServersState: { + servers: undefined as any, + isLoading: false, + }, workspaceQueryState: { allWorkspaces: undefined as any, workspaces: undefined as any, @@ -35,7 +41,6 @@ const { | undefined, }, useOrganizationBillingStatusMock: vi.fn(), - serializeServersForSharingMock: vi.fn((servers) => servers), })); vi.mock("sonner", () => ({ @@ -47,17 +52,36 @@ vi.mock("sonner", () => ({ })); vi.mock("../useWorkspaces", () => ({ - useWorkspaceQueries: () => workspaceQueryState, + useWorkspaceQueries: ({ + enabled = true, + }: { + enabled?: boolean; + }) => + enabled + ? workspaceQueryState + : { + allWorkspaces: undefined, + workspaces: undefined, + isLoading: false, + }, useWorkspaceMutations: () => ({ createWorkspace: createWorkspaceMock, + bootstrapGuestServerImport: bootstrapGuestServerImportMock, ensureDefaultWorkspace: ensureDefaultWorkspaceMock, updateWorkspace: updateWorkspaceMock, updateClientConfig: updateClientConfigMock, deleteWorkspace: deleteWorkspaceMock, }), - useWorkspaceServers: () => ({ - servers: undefined, - isLoading: false, + useWorkspaceServers: ({ + workspaceId, + enabled = true, + }: { + workspaceId: string | null; + enabled?: boolean; + }) => ({ + servers: + enabled && workspaceId ? workspaceServersState.servers : undefined, + isLoading: enabled ? workspaceServersState.isLoading : false, }), })); @@ -68,7 +92,6 @@ vi.mock("../useOrganizationBilling", () => ({ vi.mock("@/lib/workspace-serialization", () => ({ deserializeServersFromConvex: vi.fn((servers) => servers ?? {}), - serializeServersForSharing: serializeServersForSharingMock, })); function createSyntheticDefaultWorkspace(): Workspace { @@ -109,16 +132,29 @@ function createAppState(workspaces: Record): AppState { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return { promise, resolve, reject }; +} + function renderUseWorkspaceState({ appState, activeOrganizationId, routeOrganizationId, isAuthenticated = true, + isAuthLoading = false, }: { appState: AppState; activeOrganizationId?: string; routeOrganizationId?: string; isAuthenticated?: boolean; + isAuthLoading?: boolean; }) { const dispatch = vi.fn<(action: AppAction) => void>(); const logger = { @@ -128,12 +164,20 @@ function renderUseWorkspaceState({ }; const result = renderHook( - ({ organizationId }: { organizationId?: string }) => + ({ + organizationId, + isAuthLoading, + isAuthenticated, + }: { + organizationId?: string; + isAuthLoading: boolean; + isAuthenticated: boolean; + }) => useWorkspaceState({ appState, dispatch, isAuthenticated, - isAuthLoading: false, + isAuthLoading, activeOrganizationId: organizationId, routeOrganizationId, logger, @@ -141,12 +185,28 @@ function renderUseWorkspaceState({ { initialProps: { organizationId: activeOrganizationId, + isAuthLoading, + isAuthenticated, }, }, ); return { ...result, + rerender: ({ + organizationId = activeOrganizationId, + isAuthLoading: nextIsAuthLoading = isAuthLoading, + isAuthenticated: nextIsAuthenticated = isAuthenticated, + }: { + organizationId?: string; + isAuthLoading?: boolean; + isAuthenticated?: boolean; + } = {}) => + result.rerender({ + organizationId, + isAuthLoading: nextIsAuthLoading, + isAuthenticated: nextIsAuthenticated, + }), dispatch, logger, }; @@ -161,11 +221,40 @@ describe("useWorkspaceState automatic workspace creation", () => { vi.clearAllMocks(); vi.useRealTimers(); localStorage.clear(); + bootstrapGuestServerImportMock.mockImplementation( + async ({ + organizationId, + preferredWorkspaceId, + sourceWorkspaces, + }: { + organizationId?: string; + preferredWorkspaceId?: string; + sourceWorkspaces: Array<{ + localWorkspaceId: string; + servers: Array<{ name: string }>; + }>; + }) => ({ + targetWorkspaceId: preferredWorkspaceId ?? "remote-workspace-id", + targetOrganizationId: organizationId, + createdWorkspace: !preferredWorkspaceId, + importedServerNames: sourceWorkspaces.flatMap((workspace) => + workspace.servers.map((server) => server.name), + ), + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: sourceWorkspaces.map( + (workspace) => workspace.localWorkspaceId, + ), + timedOut: false, + }), + ); createWorkspaceMock.mockResolvedValue("remote-workspace-id"); ensureDefaultWorkspaceMock.mockResolvedValue("default-workspace-id"); updateClientConfigMock.mockResolvedValue(undefined); updateWorkspaceMock.mockResolvedValue("remote-workspace-id"); deleteWorkspaceMock.mockResolvedValue(undefined); + workspaceServersState.servers = undefined; + workspaceServersState.isLoading = false; workspaceQueryState.allWorkspaces = []; workspaceQueryState.workspaces = []; workspaceQueryState.isLoading = false; @@ -293,19 +382,168 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("migrates real local workspaces with createWorkspace and persists the shared workspace id", async () => { + it("prefers the newest owned fallback candidate when the saved workspace is invalid", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Older owned workspace", + servers: {}, + ownerId: "user-1", + isOwnedFallbackCandidate: true, + createdAt: 1, + updatedAt: 1, + }, + { + _id: "remote-2", + name: "Newest owned workspace", + servers: {}, + ownerId: "user-1", + isOwnedFallbackCandidate: true, + createdAt: 2, + updatedAt: 2, + }, + { + _id: "remote-3", + name: "Invited workspace", + servers: {}, + ownerId: "other-user", + isOwnedFallbackCandidate: false, + createdAt: 3, + updatedAt: 3, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-missing"); + const appState = createAppState({ default: createSyntheticDefaultWorkspace(), - "local-1": createLocalWorkspace("local-1", { + }); + const { result } = renderUseWorkspaceState({ appState }); + + expect(result.current.effectiveActiveWorkspaceId).toBe("remote-2"); + await waitFor(() => { + expect(localStorage.getItem("convex-active-workspace-id")).toBe( + "remote-2", + ); + }); + }); + + it("falls back to the first remote workspace when no owned fallback candidate exists", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "First workspace", + servers: {}, + ownerId: "other-user", + isOwnedFallbackCandidate: false, + createdAt: 1, + updatedAt: 1, + }, + { + _id: "remote-2", + name: "Second workspace", + servers: {}, + ownerId: "other-user", + isOwnedFallbackCandidate: false, + createdAt: 2, + updatedAt: 2, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-missing"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result } = renderUseWorkspaceState({ appState }); + + expect(result.current.effectiveActiveWorkspaceId).toBe("remote-1"); + await waitFor(() => { + expect(localStorage.getItem("convex-active-workspace-id")).toBe( + "remote-1", + ); + }); + }); + + it("keeps fallback selection scoped to the current organization's remote workspaces", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-global", + name: "Owned workspace in another org", + servers: {}, + ownerId: "user-1", + organizationId: "org-other", + isOwnedFallbackCandidate: true, + createdAt: 10, + updatedAt: 10, + }, + { + _id: "remote-scoped", + name: "Scoped workspace", + servers: {}, + ownerId: "other-user", + organizationId: "org-scope", + isOwnedFallbackCandidate: false, + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [ + { + _id: "remote-scoped", + name: "Scoped workspace", + servers: {}, + ownerId: "other-user", + organizationId: "org-scope", + isOwnedFallbackCandidate: false, + createdAt: 1, + updatedAt: 1, + }, + ]; + localStorage.setItem("convex-active-workspace-id", "remote-missing"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + const { result } = renderUseWorkspaceState({ + appState, + activeOrganizationId: "org-scope", + }); + + expect(result.current.effectiveActiveWorkspaceId).toBe("remote-scoped"); + expect(localStorage.getItem("convex-active-workspace-id")).toBe( + "remote-scoped", + ); + }); + + it("bootstraps the active local workspace servers into the signed-in workspace", async () => { + const appState = createAppState({ + default: createLocalWorkspace("default", { name: "Imported workspace", description: "Needs migration", + isDefault: true, servers: { demo: { name: "demo", - config: { url: "https://example.com/mcp" } as any, + config: { + url: "https://example.com/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), connectionStatus: "disconnected", retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "demo-client", + } as any, }, }, }), @@ -317,31 +555,1623 @@ describe("useWorkspaceState automatic workspace creation", () => { }); await waitFor(() => { - expect(createWorkspaceMock).toHaveBeenCalledTimes(1); + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + organizationId: "org-migrate", + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "demo", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: undefined, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "demo-client", + }, + ], + }, + ], + }); }); - expect(serializeServersForSharingMock).toHaveBeenCalledWith( - appState.workspaces["local-1"].servers, - ); - expect(createWorkspaceMock).toHaveBeenCalledWith({ - organizationId: "org-migrate", - name: "Imported workspace", - description: "Needs migration", - servers: appState.workspaces["local-1"].servers, - }); await waitFor(() => { expect(dispatch).toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", - workspaceId: "local-1", + workspaceId: "default", updates: { sharedWorkspaceId: "remote-workspace-id", organizationId: "org-migrate", }, }); }); + expect(localStorage.getItem("convex-active-workspace-id")).toBe( + "remote-workspace-id", + ); + expect(createWorkspaceMock).not.toHaveBeenCalled(); expect(ensureDefaultWorkspaceMock).not.toHaveBeenCalled(); }); + it("waits for auth loading to finish before bootstrapping local workspace servers", async () => { + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Imported workspace", + description: "Needs migration", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ + appState, + isAuthLoading: true, + }); + + await Promise.resolve(); + expect(bootstrapGuestServerImportMock).not.toHaveBeenCalled(); + + rerender({ organizationId: undefined, isAuthLoading: false }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "demo", + enabled: false, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: undefined, + timeout: undefined, + useOAuth: undefined, + oauthScopes: undefined, + clientId: undefined, + }, + ], + }, + ], + }); + }); + }); + + it("carries guest-created servers into the active signed-in workspace", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + preferredWorkspaceId: "remote-1", + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }, + ], + }, + ], + }); + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("does not wait for active workspace server hydration before bootstrapping guest servers", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = undefined; + workspaceServersState.isLoading = true; + localStorage.removeItem("convex-active-workspace-id"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "demo", + enabled: false, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: undefined, + timeout: undefined, + useOAuth: undefined, + oauthScopes: undefined, + clientId: undefined, + }, + ], + }, + ], + }); + }); + }); + + it("uses the active workspace even when the local workspace still points at an old shared workspace", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Active remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + { + _id: "remote-2", + name: "Previously linked workspace", + ownerId: "user-1", + createdAt: 2, + updatedAt: 2, + servers: {}, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = [ + { + _id: "srv-linear", + workspaceId: "remote-1", + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { "X-Different": "remote-value" }, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + createdAt: 1, + updatedAt: 1, + }, + ]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + sharedWorkspaceId: "remote-2", + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + preferredWorkspaceId: "remote-1", + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }, + ], + }, + ], + }); + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: expect.objectContaining({ + sharedWorkspaceId: "remote-2", + }), + }), + ); + }); + + it("waits for auth loading to finish before importing guest servers", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ + appState, + isAuthLoading: true, + }); + + await Promise.resolve(); + expect(bootstrapGuestServerImportMock).not.toHaveBeenCalled(); + + rerender({ organizationId: undefined, isAuthLoading: false }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + preferredWorkspaceId: "remote-1", + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "demo", + enabled: false, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: undefined, + timeout: undefined, + useOAuth: undefined, + oauthScopes: undefined, + clientId: undefined, + }, + ], + }, + ], + }); + }); + }); + + it("treats a same-name remote server as already imported", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: ["linear"], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("silently skips a same-name remote server even when headers differ", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: ["linear"], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("falls back to the active authed workspace when the old shared workspace link is stale", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + sharedWorkspaceId: "remote-missing", + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + preferredWorkspaceId: "remote-1", + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }, + ], + }, + ], + }); + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("bootstraps into a backend-created default workspace when no remote workspace exists yet", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + workspaceServersState.servers = undefined; + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: true, + importedServerNames: ["linear"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + sharedWorkspaceId: "remote-missing", + servers: { + linear: { + name: "linear", + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledWith({ + sourceWorkspaces: [ + { + localWorkspaceId: "default", + servers: [ + { + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }, + ], + }, + ], + }); + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("silently skips guest servers when the remote workspace already has the same name", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: ["linear"], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + linear: { + name: "linear", + config: { url: "https://mcp.linear.app/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + + it("keeps workspace bootstrap loading active until the guest import pass finishes", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + let resolveBootstrapImport: + | ((value: { + targetWorkspaceId: string; + targetOrganizationId?: string; + createdWorkspace: boolean; + importedServerNames: string[]; + skippedExistingNameServerNames: string[]; + failedServerNames: string[]; + importedSourceWorkspaceIds: string[]; + timedOut: boolean; + }) => void) + | null = null; + bootstrapGuestServerImportMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveBootstrapImport = resolve; + }), + ); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { result, dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(result.current.isWorkspaceBootstrapLoading).toBe(true); + }); + + await act(async () => { + resolveBootstrapImport?.({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + }); + + await waitFor(() => { + expect(result.current.isWorkspaceBootstrapLoading).toBe(false); + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("stops workspace bootstrap loading after 5 seconds if guest import hangs", async () => { + vi.useFakeTimers(); + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + bootstrapGuestServerImportMock.mockImplementationOnce( + () => new Promise(() => {}), + ); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { result, rerender } = renderUseWorkspaceState({ appState }); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.isWorkspaceBootstrapLoading).toBe(true); + + await act(async () => { + vi.advanceTimersByTime(5000); + await Promise.resolve(); + }); + + expect(result.current.isWorkspaceBootstrapLoading).toBe(false); + expect(vi.mocked(toast.warning)).toHaveBeenCalledWith( + "Importing your servers took too long. Opened app without waiting.", + ); + + workspaceQueryState.workspaces = [...workspaceQueryState.workspaces]; + rerender({ organizationId: undefined }); + + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + it("does not relink a local workspace when bootstrap times out before that workspace finishes", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: [], + timedOut: true, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { dispatch } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(vi.mocked(toast.warning)).toHaveBeenCalledWith( + "Importing your servers took too long. Opened app without waiting.", + ); + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + + it("stops workspace bootstrap loading if the guest server carry-forward mutation fails", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + bootstrapGuestServerImportMock.mockRejectedValueOnce( + new Error("bootstrap failed"), + ); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Needs migration", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { result, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + expect(result.current.isWorkspaceBootstrapLoading).toBe(false); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + "Could not import guest servers after sign-in", + ); + }); + + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + }); + + it("does not retry failed guest imports on a later rerender", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + bootstrapGuestServerImportMock + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { dispatch, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + "Could not import guest servers after sign-in", + ); + }); + + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + }), + ); + + workspaceQueryState.workspaces = [...workspaceQueryState.workspaces]; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("does not retry successful guest imports on a later rerender", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + workspaceQueryState.workspaces = [...workspaceQueryState.workspaces]; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + }); + + it("does not retry same-name skipped guest imports on a later rerender", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: ["linear"], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + linear: { + name: "linear", + config: { url: "https://mcp.linear.app/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + + workspaceQueryState.workspaces = [...workspaceQueryState.workspaces]; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); + }); + }); + + it("ignores a stale guest server carry-forward result that resolves after sign-out", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const deferred = createDeferred<{ + targetWorkspaceId: string; + targetOrganizationId?: string; + createdWorkspace: boolean; + importedServerNames: string[]; + skippedExistingNameServerNames: string[]; + failedServerNames: string[]; + importedSourceWorkspaceIds: string[]; + timedOut: boolean; + }>(); + bootstrapGuestServerImportMock.mockImplementationOnce(() => deferred.promise); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { dispatch, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + rerender({ isAuthenticated: false }); + + await act(async () => { + deferred.resolve({ + targetWorkspaceId: "remote-stale", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + await Promise.resolve(); + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-stale", + }, + }); + expect(localStorage.getItem("convex-active-workspace-id")).toBe("remote-1"); + expect(vi.mocked(toast.error)).not.toHaveBeenCalledWith( + "Could not import guest servers after sign-in", + ); + }); + + it("ignores a stale guest server carry-forward result after sign-out and allows a new sign-in pass", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const firstDeferred = createDeferred<{ + targetWorkspaceId: string; + targetOrganizationId?: string; + createdWorkspace: boolean; + importedServerNames: string[]; + skippedExistingNameServerNames: string[]; + failedServerNames: string[]; + importedSourceWorkspaceIds: string[]; + timedOut: boolean; + }>(); + const secondDeferred = createDeferred<{ + targetWorkspaceId: string; + targetOrganizationId?: string; + createdWorkspace: boolean; + importedServerNames: string[]; + skippedExistingNameServerNames: string[]; + failedServerNames: string[]; + importedSourceWorkspaceIds: string[]; + timedOut: boolean; + }>(); + bootstrapGuestServerImportMock + .mockImplementationOnce(() => firstDeferred.promise) + .mockImplementationOnce(() => secondDeferred.promise); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { dispatch, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + rerender({ isAuthenticated: false }); + rerender({ isAuthenticated: true }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + firstDeferred.resolve({ + targetWorkspaceId: "remote-stale", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + await Promise.resolve(); + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-stale", + }, + }); + expect(localStorage.getItem("convex-active-workspace-id")).toBe("remote-1"); + + await act(async () => { + secondDeferred.resolve({ + targetWorkspaceId: "remote-2", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-2", + }, + }); + }); + }); + + it("clears the completed bootstrap guard after sign-out", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + localStorage.setItem("convex-active-workspace-id", "remote-1"); + + const appState = createAppState({ + default: createLocalWorkspace("default", { + name: "Default", + description: "Default workspace", + isDefault: true, + servers: { + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, + lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), + connectionStatus: "disconnected", + retryCount: 0, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + }); + + rerender({ isAuthenticated: false }); + rerender({ isAuthenticated: true }); + + await waitFor(() => { + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(2); + }); + }); + + it("ignores a stale workspace migration result that resolves after sign-out", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + + const deferred = createDeferred(); + createWorkspaceMock.mockImplementationOnce(() => deferred.promise); + + const appState = createAppState({ + "workspace-1": createLocalWorkspace("workspace-1", { + name: "Needs migration", + description: "Needs migration", + }), + }); + + const { dispatch, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(createWorkspaceMock).toHaveBeenCalledTimes(1); + }); + + rerender({ isAuthenticated: false }); + + await act(async () => { + deferred.resolve("remote-stale"); + await Promise.resolve(); + }); + + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "workspace-1", + updates: { + sharedWorkspaceId: "remote-stale", + }, + }); + }); + + it("does not send ensure-default twice on a same-org sign-out/sign-in flicker", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + + const deferred = createDeferred(); + ensureDefaultWorkspaceMock.mockImplementationOnce(() => deferred.promise); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + + const { rerender } = renderUseWorkspaceState({ + appState, + activeOrganizationId: "org-empty", + }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + expect(ensureDefaultWorkspaceMock).toHaveBeenLastCalledWith({ + organizationId: "org-empty", + }); + }); + + rerender({ isAuthenticated: false }); + + await act(async () => { + deferred.resolve("default-workspace-id-stale"); + await Promise.resolve(); + }); + + rerender({ isAuthenticated: true, organizationId: "org-empty" }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledWith({ + organizationId: "org-empty", + }); + }); + }); + + it("allows ensure-default to run again after an org change even if the old org request finishes later", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + + const deferred = createDeferred(); + ensureDefaultWorkspaceMock + .mockImplementationOnce(() => deferred.promise) + .mockResolvedValueOnce("default-workspace-id-org-b"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + + const { rerender } = renderUseWorkspaceState({ + appState, + activeOrganizationId: "org-a", + }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + expect(ensureDefaultWorkspaceMock).toHaveBeenLastCalledWith({ + organizationId: "org-a", + }); + }); + + rerender({ organizationId: "org-b" }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(2); + expect(ensureDefaultWorkspaceMock).toHaveBeenLastCalledWith({ + organizationId: "org-b", + }); + }); + + await act(async () => { + deferred.resolve("default-workspace-id-org-a"); + await Promise.resolve(); + }); + + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(2); + }); + + it("clears ensure-default in-flight dedupe after a failure so a later retry is allowed", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + + ensureDefaultWorkspaceMock + .mockRejectedValueOnce(new Error("ensure failed")) + .mockResolvedValueOnce("default-workspace-id-next"); + + const appState = createAppState({ + default: createSyntheticDefaultWorkspace(), + }); + + const { rerender } = renderUseWorkspaceState({ + appState, + activeOrganizationId: "org-empty", + }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + }); + + rerender({ isAuthenticated: false }); + rerender({ isAuthenticated: true, organizationId: "org-empty" }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(2); + expect(ensureDefaultWorkspaceMock).toHaveBeenLastCalledWith({ + organizationId: "org-empty", + }); + }); + }); + it("treats the empty synthetic default as ensure-default only, not a migration candidate", async () => { const appState = createAppState({ default: createSyntheticDefaultWorkspace(), @@ -358,7 +2188,6 @@ describe("useWorkspaceState automatic workspace creation", () => { expect(ensureDefaultWorkspaceMock).toHaveBeenCalledWith({ organizationId: "org-empty", }); - expect(serializeServersForSharingMock).not.toHaveBeenCalled(); expect(createWorkspaceMock).not.toHaveBeenCalled(); rerender({ organizationId: "org-empty" }); diff --git a/mcpjam-inspector/client/src/hooks/use-app-state.ts b/mcpjam-inspector/client/src/hooks/use-app-state.ts index 41d79d06c..965253244 100644 --- a/mcpjam-inspector/client/src/hooks/use-app-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-app-state.ts @@ -126,6 +126,7 @@ export function useAppState({ useLocalFallback, remoteWorkspaces, isLoadingRemoteWorkspaces, + isWorkspaceBootstrapLoading, effectiveActiveWorkspaceId, } = workspaceState; const { handleDisconnect } = serverState; @@ -225,6 +226,7 @@ export function useAppState({ appState, isLoading, isLoadingRemoteWorkspaces, + isWorkspaceBootstrapLoading, isCloudSyncActive, activeOrganizationId, setActiveOrganizationId, diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 52e0f573c..5356bf3f7 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -40,6 +40,7 @@ import { getEffectiveServerClientCapabilities, } from "@/lib/client-config"; import { EXCALIDRAW_SERVER_NAME } from "@/lib/excalidraw-quick-connect"; +import { buildPersistedServerPayload } from "@/lib/persisted-server-payload"; import { readOnboardingState } from "@/lib/onboarding-state"; /** Skip noisy connect toast while first-run App Builder onboarding is in progress. */ @@ -328,27 +329,7 @@ export function useServerState({ (s) => s.name === serverName, ); - const config = serverEntry.config as any; - const transportType = config?.command ? "stdio" : "http"; - const url = - config?.url instanceof URL ? config.url.href : config?.url || undefined; - const headers = config?.requestInit?.headers || undefined; - - const payload = { - name: serverName, - enabled: serverEntry.enabled ?? false, - transportType, - command: config?.command, - args: config?.args, - url, - headers, - timeout: config?.timeout, - useOAuth: serverEntry.useOAuth, - oauthScopes: serverEntry.oauthFlowProfile?.scopes - ? serverEntry.oauthFlowProfile.scopes.split(",").filter(Boolean) - : undefined, - clientId: serverEntry.oauthFlowProfile?.clientId, - } as const; + const payload = buildPersistedServerPayload(serverName, serverEntry); try { if (existingServer) { diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 70e12c380..0c779aa9d 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -9,6 +9,8 @@ import { import { toast } from "sonner"; import type { AppAction, AppState, Workspace } from "@/state/app-types"; import { + type BootstrapGuestSourceWorkspace, + type RemoteWorkspace, useWorkspaceMutations, useWorkspaceQueries, useWorkspaceServers, @@ -22,10 +24,14 @@ import { type WorkspaceClientConfig, } from "@/lib/client-config"; import { getBillingErrorMessage } from "@/lib/billing-entitlements"; +import { + buildCarryForwardServerPayload, +} from "@/lib/persisted-server-payload"; import { useClientConfigStore } from "@/stores/client-config-store"; import { useOrganizationBillingStatus } from "./useOrganizationBilling"; const CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS = 10000; +const GUEST_SERVER_CARRY_FORWARD_TIMEOUT_MS = 5000; function stringifyWorkspaceClientConfig( clientConfig: WorkspaceClientConfig | undefined, @@ -47,6 +53,10 @@ interface LoggerLike { error: (message: string, meta?: Record) => void; } +function buildGenerationKey(generation: number, key: string) { + return `${generation}:${key}`; +} + function isSyntheticDefaultWorkspace(workspace: Workspace) { return ( workspace.id === "default" && @@ -59,6 +69,25 @@ function isSyntheticDefaultWorkspace(workspace: Workspace) { ); } +function selectFallbackRemoteWorkspace(remoteWorkspaces: RemoteWorkspace[]) { + const ownedFallbackWorkspace = remoteWorkspaces.reduce( + (newestWorkspace, workspace) => { + if (!workspace.isOwnedFallbackCandidate) { + return newestWorkspace; + } + if (!newestWorkspace) { + return workspace; + } + return workspace.createdAt > newestWorkspace.createdAt + ? workspace + : newestWorkspace; + }, + null, + ); + + return ownedFallbackWorkspace ?? remoteWorkspaces[0]; +} + export interface UseWorkspaceStateParams { appState: AppState; dispatch: Dispatch; @@ -79,6 +108,58 @@ export function useWorkspaceState({ logger, }: UseWorkspaceStateParams) { const workspaceOrganizationId = routeOrganizationId ?? activeOrganizationId; + const [convexActiveWorkspaceId, setConvexActiveWorkspaceId] = useState< + string | null + >(() => { + if (typeof window !== "undefined") { + return localStorage.getItem("convex-active-workspace-id"); + } + return null; + }); + const [useLocalFallback, setUseLocalFallback] = useState(false); + + // Guest server bootstrap: when a guest signs in, import their local servers + // into Convex via a single mutation. Queries are paused until done so the + // UI never shows a partially-imported workspace. + const [isWorkspaceBootstrapLoading, setIsWorkspaceBootstrapLoading] = + useState(false); + const [hasCompletedBootstrapImport, setHasCompletedBootstrapImport] = + useState(false); + const operationContextKey = + isAuthenticated && !useLocalFallback + ? `auth:${workspaceOrganizationId ?? "fallback"}` + : useLocalFallback + ? "local-fallback" + : "signed-out"; + const carryForwardLocalWorkspaces = useMemo( + () => + Object.values(appState.workspaces).filter( + (workspace) => Object.keys(workspace.servers).length > 0, + ), + [appState.workspaces], + ); + const bootstrapGuestSourceWorkspaces = + useMemo( + () => + carryForwardLocalWorkspaces + .map((workspace) => ({ + localWorkspaceId: workspace.id, + servers: Object.entries(workspace.servers).map( + ([serverName, server]) => + buildCarryForwardServerPayload(serverName, server), + ), + })) + .filter((workspace) => workspace.servers.length > 0), + [carryForwardLocalWorkspaces], + ); + const hasCarryForwardCandidates = bootstrapGuestSourceWorkspaces.length > 0; + // Hold off Convex queries until guest server carry-forward finishes, preventing + // the UI from briefly showing an empty or partial workspace. + const shouldPauseRemoteBootstrapQueries = + isAuthenticated && + !useLocalFallback && + hasCarryForwardCandidates && + !hasCompletedBootstrapImport; const { allWorkspaces: allRemoteWorkspaces, workspaces: remoteWorkspaces, @@ -86,9 +167,11 @@ export function useWorkspaceState({ } = useWorkspaceQueries({ isAuthenticated, organizationId: workspaceOrganizationId, + enabled: !shouldPauseRemoteBootstrapQueries, }); const { createWorkspace: convexCreateWorkspace, + bootstrapGuestServerImport: convexBootstrapGuestServerImport, ensureDefaultWorkspace: convexEnsureDefaultWorkspace, updateWorkspace: convexUpdateWorkspace, updateClientConfig: convexUpdateClientConfig, @@ -99,27 +182,26 @@ export function useWorkspaceState({ { enabled: isAuthenticated }, ); - const [convexActiveWorkspaceId, setConvexActiveWorkspaceId] = useState< - string | null - >(() => { - if (typeof window !== "undefined") { - return localStorage.getItem("convex-active-workspace-id"); - } - return null; - }); - const { servers: activeWorkspaceServersFlat, isLoading: isLoadingServers } = useWorkspaceServers({ workspaceId: convexActiveWorkspaceId, isAuthenticated, + enabled: !shouldPauseRemoteBootstrapQueries, }); + const operationGenerationRef = useRef(0); + const operationContextKeyRef = useRef(operationContextKey); const migrationInFlightRef = useRef(new Set()); + const bootstrapMutationInFlightGenerationRef = useRef(null); + const completedBootstrapGenerationRef = useRef(null); const ensureDefaultInFlightRef = useRef(new Set()); const ensureDefaultCompletedRef = useRef(new Set()); const migrationErrorNotifiedRef = useRef(new Set()); - const [useLocalFallback, setUseLocalFallback] = useState(false); const convexTimeoutRef = useRef(null); + const workspaceBootstrapTimeoutRef = useRef | null>( + null, + ); + const workspaceBootstrapTimeoutGenerationRef = useRef(null); const pendingClientConfigSyncRef = useRef( null, ); @@ -139,6 +221,54 @@ export function useWorkspaceState({ } }, []); + const clearWorkspaceBootstrapTimeout = useCallback(() => { + if (workspaceBootstrapTimeoutRef.current) { + clearTimeout(workspaceBootstrapTimeoutRef.current); + workspaceBootstrapTimeoutRef.current = null; + } + workspaceBootstrapTimeoutGenerationRef.current = null; + }, []); + + const isCurrentOperationGeneration = useCallback((generation: number) => { + return operationGenerationRef.current === generation; + }, []); + + const canContinueBootstrapGeneration = useCallback((generation: number) => { + return ( + operationGenerationRef.current === generation && + completedBootstrapGenerationRef.current !== generation + ); + }, []); + + const resetOperationGeneration = useCallback(() => { + operationGenerationRef.current += 1; + migrationInFlightRef.current.clear(); + bootstrapMutationInFlightGenerationRef.current = null; + completedBootstrapGenerationRef.current = null; + migrationErrorNotifiedRef.current.clear(); + clearWorkspaceBootstrapTimeout(); + setHasCompletedBootstrapImport(false); + setIsWorkspaceBootstrapLoading(false); + return operationGenerationRef.current; + }, [clearWorkspaceBootstrapTimeout]); + + const finishWorkspaceBootstrap = useCallback( + (generation: number) => { + if (!isCurrentOperationGeneration(generation)) { + return; + } + + completedBootstrapGenerationRef.current = generation; + setHasCompletedBootstrapImport(true); + clearWorkspaceBootstrapTimeout(); + if (bootstrapMutationInFlightGenerationRef.current === generation) { + bootstrapMutationInFlightGenerationRef.current = null; + } + setIsWorkspaceBootstrapLoading(false); + }, + [clearWorkspaceBootstrapTimeout, isCurrentOperationGeneration], + ); + useEffect(() => { if (!isAuthenticated) { setUseLocalFallback(false); @@ -197,6 +327,12 @@ export function useWorkspaceState({ }; }, [clearPendingClientConfigSync]); + useEffect(() => { + return () => { + clearWorkspaceBootstrapTimeout(); + }; + }, [clearWorkspaceBootstrapTimeout]); + const isLoadingRemoteWorkspaces = (isAuthenticated && !useLocalFallback && @@ -296,8 +432,10 @@ export function useWorkspaceState({ ) { return convexActiveWorkspaceId; } - const firstId = Object.keys(effectiveWorkspaces)[0]; - return firstId || "none"; + if (remoteWorkspaces.length === 0) { + return "none"; + } + return selectFallbackRemoteWorkspace(remoteWorkspaces)._id; } return appState.activeWorkspaceId; }, [ @@ -337,7 +475,9 @@ export function useWorkspaceState({ if (savedActiveId && convexWorkspaces[savedActiveId]) { setConvexActiveWorkspaceId(savedActiveId); } else { - setConvexActiveWorkspaceId(remoteWorkspaces[0]._id); + setConvexActiveWorkspaceId( + selectFallbackRemoteWorkspace(remoteWorkspaces)._id, + ); } } } @@ -358,21 +498,63 @@ export function useWorkspaceState({ }, [convexActiveWorkspaceId]); useEffect(() => { - if (!isAuthenticated || useLocalFallback) { - migrationInFlightRef.current.clear(); - ensureDefaultInFlightRef.current.clear(); - // Intentionally NOT clearing ensureDefaultCompletedRef here — it must - // survive transient auth-state flickers so that a workspace that was - // already successfully created isn't re-created when the Convex - // subscription briefly returns an empty result during reconnection. - migrationErrorNotifiedRef.current.clear(); + if (operationContextKeyRef.current !== operationContextKey) { + operationContextKeyRef.current = operationContextKey; + resetOperationGeneration(); } - }, [isAuthenticated, useLocalFallback]); + }, [operationContextKey, resetOperationGeneration]); + + useEffect(() => { + const currentGeneration = operationGenerationRef.current; + const shouldRunBootstrapImport = + isAuthenticated && + !isAuthLoading && + !useLocalFallback && + hasCarryForwardCandidates && + !hasCompletedBootstrapImport; + + if (!shouldRunBootstrapImport) { + clearWorkspaceBootstrapTimeout(); + setIsWorkspaceBootstrapLoading(false); + return; + } + + setIsWorkspaceBootstrapLoading(true); + if (workspaceBootstrapTimeoutGenerationRef.current !== currentGeneration) { + clearWorkspaceBootstrapTimeout(); + workspaceBootstrapTimeoutGenerationRef.current = currentGeneration; + workspaceBootstrapTimeoutRef.current = setTimeout(() => { + if (!canContinueBootstrapGeneration(currentGeneration)) { + return; + } + workspaceBootstrapTimeoutRef.current = null; + workspaceBootstrapTimeoutGenerationRef.current = null; + logger.warn("Guest server carry-forward timed out", { + timeoutMs: GUEST_SERVER_CARRY_FORWARD_TIMEOUT_MS, + }); + toast.warning( + "Importing your servers took too long. Opened app without waiting.", + ); + finishWorkspaceBootstrap(currentGeneration); + }, GUEST_SERVER_CARRY_FORWARD_TIMEOUT_MS); + } + }, [ + canContinueBootstrapGeneration, + clearWorkspaceBootstrapTimeout, + finishWorkspaceBootstrap, + hasCompletedBootstrapImport, + hasCarryForwardCandidates, + isAuthenticated, + isAuthLoading, + logger, + useLocalFallback, + ]); useEffect(() => { if ( !isAuthenticated || useLocalFallback || + hasCarryForwardCandidates || allRemoteWorkspaces === undefined || allRemoteWorkspaces.length > 0 || migratableLocalWorkspaceCount === 0 @@ -382,6 +564,7 @@ export function useWorkspaceState({ }, [ isAuthenticated, useLocalFallback, + hasCarryForwardCandidates, allRemoteWorkspaces, migratableLocalWorkspaceCount, ]); @@ -390,6 +573,10 @@ export function useWorkspaceState({ if (!isAuthenticated) { return; } + if (hasCarryForwardCandidates) return; + const currentGeneration = operationGenerationRef.current; + if (completedBootstrapGenerationRef.current === currentGeneration) return; + if (isAuthLoading) return; if (useLocalFallback) return; if (allRemoteWorkspaces === undefined) return; if (allRemoteWorkspaces.length > 0) return; @@ -400,23 +587,30 @@ export function useWorkspaceState({ }); const migrateWorkspace = async (workspace: Workspace) => { - if (migrationInFlightRef.current.has(workspace.id)) { + if (!isCurrentOperationGeneration(currentGeneration)) { + return; + } + const inFlightKey = buildGenerationKey(currentGeneration, workspace.id); + if (migrationInFlightRef.current.has(inFlightKey)) { return; } - migrationInFlightRef.current.add(workspace.id); + migrationInFlightRef.current.add(inFlightKey); try { - const serializedServers = serializeServersForSharing(workspace.servers); const workspaceId = await convexCreateWorkspace({ name: workspace.name, description: workspace.description, clientConfig: workspace.clientConfig, - servers: serializedServers, + servers: {}, ...(workspaceOrganizationId ? { organizationId: workspaceOrganizationId } : {}), }); + if (!isCurrentOperationGeneration(currentGeneration)) { + return; + } + dispatch({ type: "UPDATE_WORKSPACE", workspaceId: workspace.id, @@ -427,10 +621,21 @@ export function useWorkspaceState({ : {}), }, }); + + if (appState.activeWorkspaceId === workspace.id) { + setConvexActiveWorkspaceId(workspaceId as string); + } + logger.info("Migrated workspace to Convex", { name: workspace.name }); } catch (error) { - migrationInFlightRef.current.delete(workspace.id); - const requestKey = workspaceOrganizationId ?? "fallback"; + migrationInFlightRef.current.delete(inFlightKey); + if (!isCurrentOperationGeneration(currentGeneration)) { + return; + } + const requestKey = buildGenerationKey( + currentGeneration, + workspaceOrganizationId ?? "fallback", + ); if (!migrationErrorNotifiedRef.current.has(requestKey)) { migrationErrorNotifiedRef.current.add(requestKey); toast.error( @@ -445,13 +650,18 @@ export function useWorkspaceState({ name: workspace.name, error: error instanceof Error ? error.message : "Unknown error", }); + if (hasCarryForwardCandidates) { + finishWorkspaceBootstrap(currentGeneration); + } } }; Promise.all(migratableLocalWorkspaces.map(migrateWorkspace)); }, [ isAuthenticated, + isAuthLoading, useLocalFallback, + hasCarryForwardCandidates, allRemoteWorkspaces, migratableLocalWorkspaces, migratableLocalWorkspaceCount, @@ -460,12 +670,126 @@ export function useWorkspaceState({ logger, workspaceOrganizationId, canManageBillingForWorkspaceActions, + appState.activeWorkspaceId, + finishWorkspaceBootstrap, + isCurrentOperationGeneration, + ]); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + const currentGeneration = operationGenerationRef.current; + if (isAuthLoading) return; + if (useLocalFallback) return; + if (!hasCarryForwardCandidates) return; + if (hasCompletedBootstrapImport) return; + if (bootstrapMutationInFlightGenerationRef.current === currentGeneration) { + return; + } + + bootstrapMutationInFlightGenerationRef.current = currentGeneration; + + void (async () => { + try { + const result = await convexBootstrapGuestServerImport({ + ...(workspaceOrganizationId + ? { organizationId: workspaceOrganizationId } + : {}), + ...(convexActiveWorkspaceId + ? { preferredWorkspaceId: convexActiveWorkspaceId } + : {}), + sourceWorkspaces: bootstrapGuestSourceWorkspaces, + }); + + if (!canContinueBootstrapGeneration(currentGeneration)) { + return; + } + + const targetOrganizationId = + result.targetOrganizationId ?? workspaceOrganizationId; + + setConvexActiveWorkspaceId(result.targetWorkspaceId); + localStorage.setItem( + "convex-active-workspace-id", + result.targetWorkspaceId, + ); + + for (const workspaceId of result.importedSourceWorkspaceIds) { + dispatch({ + type: "UPDATE_WORKSPACE", + workspaceId, + updates: { + sharedWorkspaceId: result.targetWorkspaceId, + ...(targetOrganizationId + ? { organizationId: targetOrganizationId } + : {}), + }, + }); + } + + if (result.failedServerNames.length > 0) { + logger.error("Failed to carry forward some guest servers", { + targetWorkspaceId: result.targetWorkspaceId, + serverNames: result.failedServerNames, + }); + toast.error( + `Could not import some guest servers after sign-in: ${result.failedServerNames.join(", ")}`, + ); + } + + if (result.timedOut) { + logger.warn("Guest server carry-forward timed out in Convex", { + targetWorkspaceId: result.targetWorkspaceId, + }); + toast.warning( + "Importing your servers took too long. Opened app without waiting.", + ); + } + + logger.info("Carried forward guest workspace servers", { + targetWorkspaceId: result.targetWorkspaceId, + importedServerCount: result.importedServerNames.length, + skippedExistingNameServerCount: + result.skippedExistingNameServerNames.length, + failedServerCount: result.failedServerNames.length, + sourceWorkspaceCount: result.importedSourceWorkspaceIds.length, + timedOut: result.timedOut, + }); + } catch (error) { + if (canContinueBootstrapGeneration(currentGeneration)) { + logger.error("Failed to carry forward guest workspace servers", { + error: error instanceof Error ? error.message : "Unknown error", + }); + toast.error("Could not import guest servers after sign-in"); + } + } finally { + finishWorkspaceBootstrap(currentGeneration); + } + })(); + }, [ + canContinueBootstrapGeneration, + isAuthenticated, + isAuthLoading, + useLocalFallback, + hasCarryForwardCandidates, + hasCompletedBootstrapImport, + bootstrapGuestSourceWorkspaces, + convexActiveWorkspaceId, + convexBootstrapGuestServerImport, + dispatch, + finishWorkspaceBootstrap, + workspaceOrganizationId, + logger, ]); useEffect(() => { if (!isAuthenticated) { return; } + if (hasCarryForwardCandidates) return; + const currentGeneration = operationGenerationRef.current; + if (completedBootstrapGenerationRef.current === currentGeneration) return; if (useLocalFallback) return; if (remoteWorkspaces === undefined) return; if (hasCurrentOrganizationWorkspaces) return; @@ -486,24 +810,34 @@ export function useWorkspaceState({ ? { organizationId: workspaceOrganizationId } : {}, ) - .then((workspaceId) => { + .then(() => { + ensureDefaultInFlightRef.current.delete(requestKey); ensureDefaultCompletedRef.current.add(requestKey); }) .catch((error) => { ensureDefaultInFlightRef.current.delete(requestKey); + if (!isCurrentOperationGeneration(currentGeneration)) { + return; + } logger.error("Failed to ensure default workspace", { organizationId: workspaceOrganizationId, error: error instanceof Error ? error.message : "Unknown error", }); + if (hasCarryForwardCandidates) { + finishWorkspaceBootstrap(currentGeneration); + } }); }, [ isAuthenticated, useLocalFallback, + hasCarryForwardCandidates, remoteWorkspaces, hasCurrentOrganizationWorkspaces, hasAnyRemoteWorkspaces, migratableLocalWorkspaceCount, convexEnsureDefaultWorkspace, + finishWorkspaceBootstrap, + isCurrentOperationGeneration, workspaceOrganizationId, logger, ]); @@ -887,6 +1221,7 @@ export function useWorkspaceState({ useLocalFallback, setConvexActiveWorkspaceId, isLoadingRemoteWorkspaces, + isWorkspaceBootstrapLoading, effectiveWorkspaces, effectiveActiveWorkspaceId, handleCreateWorkspace, diff --git a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts index ff4d6a023..2a25bf645 100644 --- a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts +++ b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts @@ -21,6 +21,7 @@ export interface RemoteWorkspace { organizationId?: string; visibility?: WorkspaceVisibility; ownerId: string; + isOwnedFallbackCandidate: boolean; createdAt: number; updatedAt: number; } @@ -49,6 +50,36 @@ export interface RemoteServer { updatedAt: number; } +export interface BootstrapGuestServerPayload { + name: string; + enabled: boolean; + transportType: "stdio" | "http"; + command?: string; + args?: string[]; + url?: string; + headers?: Record; + timeout?: number; + useOAuth?: boolean; + oauthScopes?: string[]; + clientId?: string; +} + +export interface BootstrapGuestSourceWorkspace { + localWorkspaceId: string; + servers: BootstrapGuestServerPayload[]; +} + +export interface BootstrapGuestServerImportResult { + targetWorkspaceId: string; + targetOrganizationId?: string; + createdWorkspace: boolean; + importedServerNames: string[]; + skippedExistingNameServerNames: string[]; + failedServerNames: string[]; + importedSourceWorkspaceIds: string[]; + timedOut: boolean; +} + export interface WorkspaceMember { _id: string; workspaceId: string; @@ -128,16 +159,18 @@ export function filterWorkspacesForOrganization( export function useWorkspaceQueries({ isAuthenticated, organizationId, + enabled = true, }: { isAuthenticated: boolean; organizationId?: string; + enabled?: boolean; }) { const queriedWorkspaces = useQuery( "workspaces:getMyWorkspaces" as any, - isAuthenticated ? ({} as any) : "skip", + isAuthenticated && enabled ? ({} as any) : "skip", ) as RemoteWorkspace[] | undefined; - const isLoading = isAuthenticated && queriedWorkspaces === undefined; + const isLoading = isAuthenticated && enabled && queriedWorkspaces === undefined; const workspaces = useMemo( () => filterWorkspacesForOrganization(queriedWorkspaces, organizationId), @@ -202,6 +235,9 @@ export function useWorkspaceMembers({ export function useWorkspaceMutations() { const createWorkspace = useMutation("workspaces:createWorkspace" as any); + const bootstrapGuestServerImport = useMutation( + "workspaces:bootstrapGuestServerImport" as any, + ); const ensureDefaultWorkspace = useMutation( "workspaces:ensureDefaultWorkspace" as any, ); @@ -225,6 +261,7 @@ export function useWorkspaceMutations() { return { createWorkspace, + bootstrapGuestServerImport, ensureDefaultWorkspace, updateWorkspace, updateClientConfig, @@ -252,16 +289,19 @@ export function useServerMutations() { export function useWorkspaceServers({ workspaceId, isAuthenticated, + enabled = true, }: { workspaceId: string | null; isAuthenticated: boolean; + enabled?: boolean; }) { const servers = useQuery( "servers:getWorkspaceServers" as any, - isAuthenticated && workspaceId ? ({ workspaceId } as any) : "skip", + isAuthenticated && enabled && workspaceId ? ({ workspaceId } as any) : "skip", ) as RemoteServer[] | undefined; - const isLoading = isAuthenticated && workspaceId && servers === undefined; + const isLoading = + isAuthenticated && enabled && workspaceId && servers === undefined; // Convert array to record keyed by server name const serversRecord = useMemo(() => { diff --git a/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts new file mode 100644 index 000000000..1869f1687 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { + buildCarryForwardServerPayload, + buildPersistedServerPayload, +} from "../persisted-server-payload"; + +describe("persisted-server-payload", () => { + it("preserves Authorization and custom headers for normal persistence", () => { + const payload = buildPersistedServerPayload("linear", { + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }); + + expect(payload).toEqual({ + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }); + }); + + it("excludes runtime-only state from the persisted payload", () => { + const payload = buildPersistedServerPayload("demo", { + config: { url: "https://example.com/mcp" } as any, + enabled: false, + useOAuth: false, + oauthFlowProfile: undefined, + }); + + expect(payload).not.toHaveProperty("oauthTokens"); + expect(payload).toEqual({ + name: "demo", + enabled: false, + transportType: "http", + command: undefined, + args: undefined, + url: "https://example.com/mcp", + headers: undefined, + timeout: undefined, + useOAuth: false, + oauthScopes: undefined, + clientId: undefined, + }); + }); + + it("carry-forward payload omits all headers including sensitive ones", () => { + const payload = buildCarryForwardServerPayload("linear", { + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-API-Key": "key-123", + Cookie: "session=abc", + "X-Custom": "1", + }, + }, + timeout: 30_000, + } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }); + + expect(payload.headers).toBeUndefined(); + expect(payload.useOAuth).toBe(true); + expect(payload.oauthScopes).toEqual(["read", "write"]); + expect(payload.clientId).toBe("linear-client"); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts new file mode 100644 index 000000000..8300d7c27 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -0,0 +1,97 @@ +import type { ServerWithName } from "@/state/app-types"; + +export interface PersistedServerPayload { + name: string; + enabled: boolean; + transportType: "stdio" | "http"; + command?: string; + args?: string[]; + url?: string; + headers?: Record; + timeout?: number; + useOAuth?: boolean; + oauthScopes?: string[]; + clientId?: string; +} + +function normalizeHeaders( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = String(value); + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeScopes(scopes: string[] | string | undefined): string[] | undefined { + if (Array.isArray(scopes)) { + return scopes.length > 0 ? [...scopes] : undefined; + } + + if (typeof scopes === "string") { + const parsed = scopes + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean); + return parsed.length > 0 ? parsed : undefined; + } + + return undefined; +} + +export function buildPersistedServerPayload( + serverName: string, + serverEntry: Pick< + ServerWithName, + "config" | "enabled" | "useOAuth" | "oauthFlowProfile" + >, +): PersistedServerPayload { + const config = serverEntry.config as Record; + const transportType = config.command ? "stdio" : "http"; + const rawUrl = config.url as string | URL | undefined; + const rawRequestInit = config.requestInit as + | { headers?: Record } + | undefined; + const oauthScopes = normalizeScopes(serverEntry.oauthFlowProfile?.scopes); + + return { + name: serverName, + enabled: serverEntry.enabled ?? false, + transportType, + command: typeof config.command === "string" ? config.command : undefined, + args: Array.isArray(config.args) + ? (config.args as string[]) + : undefined, + url: + rawUrl instanceof URL + ? rawUrl.href + : typeof rawUrl === "string" + ? rawUrl + : undefined, + headers: normalizeHeaders(rawRequestInit?.headers), + timeout: typeof config.timeout === "number" ? config.timeout : undefined, + useOAuth: serverEntry.useOAuth, + oauthScopes, + clientId: serverEntry.oauthFlowProfile?.clientId || undefined, + }; +} + +export function buildCarryForwardServerPayload( + serverName: string, + serverEntry: Pick< + ServerWithName, + "config" | "enabled" | "useOAuth" | "oauthFlowProfile" + >, +): PersistedServerPayload { + const payload = buildPersistedServerPayload(serverName, serverEntry); + + // Guest headers are intentionally dropped so guest-only secrets are not + // uploaded into workspace data during guest -> signed-in carry-forward. + return { ...payload, headers: undefined }; +}