From 83d170d2ca9b48c5d7ff94b8e3f9023285bc3d1a Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:38:40 -0700 Subject: [PATCH 1/6] guets servers persist a login --- .../__tests__/use-workspace-state.test.tsx | 368 +++++++++++++++++- .../client/src/hooks/use-server-state.ts | 23 +- .../client/src/hooks/use-workspace-state.ts | 157 ++++++++ .../persisted-server-payload.test.ts | 141 +++++++ .../src/lib/persisted-server-payload.ts | 204 ++++++++++ 5 files changed, 869 insertions(+), 24 deletions(-) create mode 100644 mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts create mode 100644 mcpjam-inspector/client/src/lib/persisted-server-payload.ts 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..d29afd7d4 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,28 @@ import { useClientConfigStore } from "@/stores/client-config-store"; import type { WorkspaceClientConfig } from "@/lib/client-config"; const { + createServerMock, createWorkspaceMock, ensureDefaultWorkspaceMock, updateClientConfigMock, updateWorkspaceMock, deleteWorkspaceMock, + workspaceServersState, workspaceQueryState, organizationBillingStatusState, useOrganizationBillingStatusMock, serializeServersForSharingMock, } = vi.hoisted(() => ({ + createServerMock: 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, @@ -55,9 +62,16 @@ vi.mock("../useWorkspaces", () => ({ updateClientConfig: updateClientConfigMock, deleteWorkspace: deleteWorkspaceMock, }), - useWorkspaceServers: () => ({ - servers: undefined, - isLoading: false, + useServerMutations: () => ({ + createServer: createServerMock, + }), + useWorkspaceServers: ({ + workspaceId, + }: { + workspaceId: string | null; + }) => ({ + servers: workspaceId ? workspaceServersState.servers : undefined, + isLoading: workspaceServersState.isLoading, }), })); @@ -162,10 +176,13 @@ describe("useWorkspaceState automatic workspace creation", () => { vi.useRealTimers(); localStorage.clear(); createWorkspaceMock.mockResolvedValue("remote-workspace-id"); + createServerMock.mockResolvedValue("remote-server-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; @@ -342,6 +359,351 @@ describe("useWorkspaceState automatic workspace creation", () => { expect(ensureDefaultWorkspaceMock).not.toHaveBeenCalled(); }); + 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(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: { "X-Custom": "1" }, + 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("waits for active workspace server hydration 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 = 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, + }, + }, + }), + }); + + const { rerender } = renderUseWorkspaceState({ appState }); + + await Promise.resolve(); + expect(createServerMock).not.toHaveBeenCalled(); + + workspaceServersState.servers = []; + workspaceServersState.isLoading = false; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + 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 an equivalent 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]; + workspaceServersState.servers = [ + { + _id: "srv-linear", + workspaceId: "remote-1", + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { "X-Custom": "1" }, + 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, + 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(createServerMock).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + + it("does not overwrite conflicting remote servers with the same name", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = [ + { + _id: "srv-linear", + workspaceId: "remote-1", + name: "linear", + enabled: true, + transportType: "http", + url: "https://different.example.com/mcp", + headers: undefined, + timeout: undefined, + useOAuth: false, + oauthScopes: undefined, + clientId: undefined, + 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, + 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(createServerMock).not.toHaveBeenCalled(); + expect(vi.mocked(toast.error)).toHaveBeenCalledWith( + "Some guest servers were not imported because those names already exist: linear", + ); + }); + + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + }), + ); + }); + + it("keeps failed guest imports retryable 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"); + + createServerMock + .mockRejectedValueOnce(new Error("network down")) + .mockResolvedValueOnce("remote-demo"); + + 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 some guest servers after sign-in: demo", + ); + }); + + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + }), + ); + + workspaceQueryState.workspaces = [...workspaceQueryState.workspaces]; + rerender({ organizationId: undefined }); + + await waitFor(() => { + expect(createServerMock).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + }); + it("treats the empty synthetic default as ensure-default only, not a migration candidate", async () => { const appState = createAppState({ default: createSyntheticDefaultWorkspace(), 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..89436ced5 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 RemoteServer, + useServerMutations, useWorkspaceMutations, useWorkspaceQueries, useWorkspaceServers, @@ -22,6 +24,11 @@ import { type WorkspaceClientConfig, } from "@/lib/client-config"; import { getBillingErrorMessage } from "@/lib/billing-entitlements"; +import { + buildPersistedServerPayload, + buildRemoteServerFromPersistedPayload, + isRemoteServerEquivalent, +} from "@/lib/persisted-server-payload"; import { useClientConfigStore } from "@/stores/client-config-store"; import { useOrganizationBillingStatus } from "./useOrganizationBilling"; @@ -98,6 +105,7 @@ export function useWorkspaceState({ workspaceOrganizationId ?? null, { enabled: isAuthenticated }, ); + const { createServer: convexCreateServer } = useServerMutations(); const [convexActiveWorkspaceId, setConvexActiveWorkspaceId] = useState< string | null @@ -115,6 +123,7 @@ export function useWorkspaceState({ }); const migrationInFlightRef = useRef(new Set()); + const carryForwardInFlightRef = useRef(new Set()); const ensureDefaultInFlightRef = useRef(new Set()); const ensureDefaultCompletedRef = useRef(new Set()); const migrationErrorNotifiedRef = useRef(new Set()); @@ -318,6 +327,15 @@ export function useWorkspaceState({ ), [appState.workspaces], ); + const carryForwardLocalWorkspaces = useMemo( + () => + Object.values(appState.workspaces).filter( + (workspace) => + !workspace.sharedWorkspaceId && + Object.keys(workspace.servers).length > 0, + ), + [appState.workspaces], + ); const migratableLocalWorkspaceCount = migratableLocalWorkspaces.length; const hasAnyRemoteWorkspaces = (allRemoteWorkspaces?.length ?? 0) > 0; const hasCurrentOrganizationWorkspaces = (remoteWorkspaces?.length ?? 0) > 0; @@ -360,6 +378,7 @@ export function useWorkspaceState({ useEffect(() => { if (!isAuthenticated || useLocalFallback) { migrationInFlightRef.current.clear(); + carryForwardInFlightRef.current.clear(); ensureDefaultInFlightRef.current.clear(); // Intentionally NOT clearing ensureDefaultCompletedRef here — it must // survive transient auth-state flickers so that a workspace that was @@ -462,6 +481,144 @@ export function useWorkspaceState({ canManageBillingForWorkspaceActions, ]); + useEffect(() => { + if (!isAuthenticated) { + return; + } + if (useLocalFallback) return; + if (allRemoteWorkspaces === undefined) return; + if (allRemoteWorkspaces.length === 0) return; + if (!convexActiveWorkspaceId) return; + + const targetWorkspace = convexWorkspaces[convexActiveWorkspaceId]; + if (!targetWorkspace) return; + if (activeWorkspaceServersFlat === undefined) return; + if (carryForwardLocalWorkspaces.length === 0) return; + + const targetOrganizationId = + targetWorkspace.organizationId ?? activeOrganizationId; + + const carryForwardWorkspace = async ( + workspace: Workspace, + targetServersByName: Map, + ) => { + const inFlightKey = `${convexActiveWorkspaceId}:${workspace.id}`; + if (carryForwardInFlightRef.current.has(inFlightKey)) { + return; + } + + carryForwardInFlightRef.current.add(inFlightKey); + + const conflictNames: string[] = []; + const failedNames: string[] = []; + + try { + for (const [serverName, server] of Object.entries(workspace.servers)) { + const existingRemoteServer = targetServersByName.get(serverName); + + if (existingRemoteServer) { + if (isRemoteServerEquivalent(server, existingRemoteServer)) { + continue; + } + + conflictNames.push(serverName); + continue; + } + + const payload = buildPersistedServerPayload(serverName, server); + + try { + const createdServerId = await convexCreateServer({ + workspaceId: convexActiveWorkspaceId, + ...payload, + }); + + targetServersByName.set( + serverName, + buildRemoteServerFromPersistedPayload({ + payload, + workspaceId: convexActiveWorkspaceId, + serverId: + typeof createdServerId === "string" + ? createdServerId + : undefined, + }), + ); + } catch (error) { + failedNames.push(serverName); + logger.error("Failed to carry forward guest server", { + workspaceId: workspace.id, + targetWorkspaceId: convexActiveWorkspaceId, + serverName, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + if (conflictNames.length === 0 && failedNames.length === 0) { + dispatch({ + type: "UPDATE_WORKSPACE", + workspaceId: workspace.id, + updates: { + sharedWorkspaceId: convexActiveWorkspaceId, + ...(targetOrganizationId + ? { organizationId: targetOrganizationId } + : {}), + }, + }); + + logger.info("Carried forward guest workspace servers", { + workspaceId: workspace.id, + targetWorkspaceId: convexActiveWorkspaceId, + serverCount: Object.keys(workspace.servers).length, + }); + return; + } + + if (conflictNames.length > 0) { + logger.warn("Guest workspace server carry-forward conflicts", { + workspaceId: workspace.id, + targetWorkspaceId: convexActiveWorkspaceId, + serverNames: conflictNames, + }); + toast.error( + `Some guest servers were not imported because those names already exist: ${conflictNames.join(", ")}`, + ); + } + + if (failedNames.length > 0) { + toast.error( + `Could not import some guest servers after sign-in: ${failedNames.join(", ")}`, + ); + } + } finally { + carryForwardInFlightRef.current.delete(inFlightKey); + } + }; + + void (async () => { + const targetServersByName = new Map( + activeWorkspaceServersFlat.map((server) => [server.name, server]), + ); + + for (const workspace of carryForwardLocalWorkspaces) { + await carryForwardWorkspace(workspace, targetServersByName); + } + })(); + }, [ + isAuthenticated, + useLocalFallback, + allRemoteWorkspaces, + convexActiveWorkspaceId, + convexWorkspaces, + activeWorkspaceServersFlat, + carryForwardLocalWorkspaces, + convexCreateServer, + dispatch, + activeOrganizationId, + logger, + ]); + useEffect(() => { if (!isAuthenticated) { return; 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..58cf5e4e9 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { + buildPersistedPayloadFromRemoteServer, + buildPersistedServerPayload, + isRemoteServerEquivalent, + persistedServerPayloadsEqual, +} from "../persisted-server-payload"; + +describe("persisted-server-payload", () => { + it("strips Authorization while preserving non-secret headers", () => { + 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: { + "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("treats matching sanitized local and remote servers as equivalent", () => { + const localPayload = buildPersistedServerPayload("linear", { + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }); + const remotePayload = buildPersistedPayloadFromRemoteServer({ + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { + "X-Custom": "1", + }, + timeout: undefined, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }); + + expect(persistedServerPayloadsEqual(localPayload, remotePayload)).toBe( + true, + ); + expect( + isRemoteServerEquivalent( + { + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + { + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { + "X-Custom": "1", + }, + timeout: undefined, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + } as any, + ), + ).toBe(true); + }); +}); 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..8447a8941 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -0,0 +1,204 @@ +import type { ServerWithName } from "@/state/app-types"; +import type { RemoteServer } from "@/hooks/useWorkspaces"; + +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 stripAuthorizationHeader( + headers: Record | undefined, +): Record | undefined { + if (!headers) { + return undefined; + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === "authorization") { + continue; + } + + sanitized[key] = String(value); + } + + return Object.keys(sanitized).length > 0 ? sanitized : 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: stripAuthorizationHeader(rawRequestInit?.headers), + timeout: typeof config.timeout === "number" ? config.timeout : undefined, + useOAuth: serverEntry.useOAuth, + oauthScopes, + clientId: serverEntry.oauthFlowProfile?.clientId || undefined, + }; +} + +export function buildPersistedPayloadFromRemoteServer( + remoteServer: Pick< + RemoteServer, + | "name" + | "enabled" + | "transportType" + | "command" + | "args" + | "url" + | "headers" + | "timeout" + | "useOAuth" + | "oauthScopes" + | "clientId" + >, +): PersistedServerPayload { + return { + name: remoteServer.name, + enabled: remoteServer.enabled, + transportType: remoteServer.transportType, + command: remoteServer.command, + args: remoteServer.args ? [...remoteServer.args] : undefined, + url: remoteServer.url, + headers: stripAuthorizationHeader(remoteServer.headers), + timeout: remoteServer.timeout, + useOAuth: remoteServer.useOAuth, + oauthScopes: normalizeScopes(remoteServer.oauthScopes), + clientId: remoteServer.clientId, + }; +} + +function normalizePayload( + payload: PersistedServerPayload, +): PersistedServerPayload { + return { + ...payload, + args: payload.args ? [...payload.args] : undefined, + headers: payload.headers + ? Object.fromEntries( + Object.entries(payload.headers).sort(([left], [right]) => + left.localeCompare(right), + ), + ) + : undefined, + oauthScopes: payload.oauthScopes ? [...payload.oauthScopes] : undefined, + }; +} + +export function persistedServerPayloadsEqual( + left: PersistedServerPayload, + right: PersistedServerPayload, +): boolean { + return ( + JSON.stringify(normalizePayload(left)) === + JSON.stringify(normalizePayload(right)) + ); +} + +export function isRemoteServerEquivalent( + localServer: Pick< + ServerWithName, + "config" | "enabled" | "useOAuth" | "oauthFlowProfile" + >, + remoteServer: Pick< + RemoteServer, + | "name" + | "enabled" + | "transportType" + | "command" + | "args" + | "url" + | "headers" + | "timeout" + | "useOAuth" + | "oauthScopes" + | "clientId" + >, +): boolean { + return persistedServerPayloadsEqual( + buildPersistedServerPayload(remoteServer.name, localServer), + buildPersistedPayloadFromRemoteServer(remoteServer), + ); +} + +export function buildRemoteServerFromPersistedPayload(args: { + payload: PersistedServerPayload; + workspaceId: string; + serverId?: string; + createdAt?: number; + updatedAt?: number; +}): RemoteServer { + const now = Date.now(); + + return { + _id: args.serverId ?? `persisted:${args.payload.name}`, + workspaceId: args.workspaceId, + name: args.payload.name, + enabled: args.payload.enabled, + transportType: args.payload.transportType, + command: args.payload.command, + args: args.payload.args ? [...args.payload.args] : undefined, + url: args.payload.url, + headers: args.payload.headers + ? { ...args.payload.headers } + : undefined, + timeout: args.payload.timeout, + useOAuth: args.payload.useOAuth, + oauthScopes: args.payload.oauthScopes + ? [...args.payload.oauthScopes] + : undefined, + clientId: args.payload.clientId, + createdAt: args.createdAt ?? now, + updatedAt: args.updatedAt ?? now, + }; +} From dfd60ffd4e81b41e720ee0deb031969793a8127e Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:40:54 -0700 Subject: [PATCH 2/6] - --- .../client/src/components/ServersTab.tsx | 1 - .../__tests__/use-workspace-state.test.tsx | 524 +++++++++++++++++- .../client/src/hooks/use-workspace-state.ts | 94 +++- .../persisted-server-payload.test.ts | 129 ++++- .../src/lib/persisted-server-payload.ts | 71 ++- 5 files changed, 756 insertions(+), 63 deletions(-) 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-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index d29afd7d4..a0a5de13d 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 @@ -17,7 +17,6 @@ const { workspaceQueryState, organizationBillingStatusState, useOrganizationBillingStatusMock, - serializeServersForSharingMock, } = vi.hoisted(() => ({ createServerMock: vi.fn(), createWorkspaceMock: vi.fn(), @@ -42,7 +41,6 @@ const { | undefined, }, useOrganizationBillingStatusMock: vi.fn(), - serializeServersForSharingMock: vi.fn((servers) => servers), })); vi.mock("sonner", () => ({ @@ -82,7 +80,6 @@ vi.mock("../useOrganizationBilling", () => ({ vi.mock("@/lib/workspace-serialization", () => ({ deserializeServersFromConvex: vi.fn((servers) => servers ?? {}), - serializeServersForSharing: serializeServersForSharingMock, })); function createSyntheticDefaultWorkspace(): Workspace { @@ -128,11 +125,13 @@ function renderUseWorkspaceState({ 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 = { @@ -142,12 +141,18 @@ function renderUseWorkspaceState({ }; const result = renderHook( - ({ organizationId }: { organizationId?: string }) => + ({ + organizationId, + isAuthLoading, + }: { + organizationId?: string; + isAuthLoading: boolean; + }) => useWorkspaceState({ appState, dispatch, isAuthenticated, - isAuthLoading: false, + isAuthLoading, activeOrganizationId: organizationId, routeOrganizationId, logger, @@ -155,6 +160,7 @@ function renderUseWorkspaceState({ { initialProps: { organizationId: activeOrganizationId, + isAuthLoading, }, }, ); @@ -310,19 +316,34 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("migrates real local workspaces with createWorkspace and persists the shared workspace id", async () => { + it("migrates the active local workspace servers into the new signed-in workspace", async () => { const appState = createAppState({ - default: createSyntheticDefaultWorkspace(), - "local-1": createLocalWorkspace("local-1", { + 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, }, }, }), @@ -337,28 +358,93 @@ describe("useWorkspaceState automatic workspace creation", () => { expect(createWorkspaceMock).toHaveBeenCalledTimes(1); }); - 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, + clientConfig: undefined, + servers: {}, + }); + await waitFor(() => { + expect(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-workspace-id", + 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", + }); }); 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(ensureDefaultWorkspaceMock).not.toHaveBeenCalled(); }); + it("waits for auth loading to finish before migrating local workspaces", 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(createWorkspaceMock).not.toHaveBeenCalled(); + expect(createServerMock).not.toHaveBeenCalled(); + + rerender({ organizationId: undefined, isAuthLoading: false }); + + await waitFor(() => { + expect(createWorkspaceMock).toHaveBeenCalledTimes(1); + expect(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-workspace-id", + 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 = [ { @@ -417,7 +503,7 @@ describe("useWorkspaceState automatic workspace creation", () => { command: undefined, args: undefined, url: "https://mcp.linear.app/mcp", - headers: { "X-Custom": "1" }, + headers: undefined, timeout: 30_000, useOAuth: true, oauthScopes: ["read", "write"], @@ -496,6 +582,170 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + 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, logger } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(logger.info).toHaveBeenCalledWith( + "Carried forward guest workspace servers", + expect.objectContaining({ + workspaceId: "default", + targetWorkspaceId: "remote-1", + serverCount: 1, + }), + ); + }); + + expect(createServerMock).not.toHaveBeenCalled(); + 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(createServerMock).not.toHaveBeenCalled(); + + rerender({ organizationId: undefined, isAuthLoading: false }); + + await waitFor(() => { + expect(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + 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 an equivalent remote server as already imported", async () => { workspaceQueryState.allWorkspaces = [ { @@ -573,6 +823,249 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + it("treats a remote server with different headers as already imported during carry-forward", async () => { + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + 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, + 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(createServerMock).not.toHaveBeenCalled(); + 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(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + 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("waits for a default cloud workspace before carrying forward a stale-linked local workspace", async () => { + workspaceQueryState.allWorkspaces = []; + workspaceQueryState.workspaces = []; + workspaceServersState.servers = undefined; + + 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, rerender } = renderUseWorkspaceState({ appState }); + + await waitFor(() => { + expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + }); + + expect(createServerMock).not.toHaveBeenCalled(); + + workspaceQueryState.allWorkspaces = [ + { + _id: "remote-1", + name: "Remote workspace", + servers: {}, + ownerId: "user-1", + createdAt: 1, + updatedAt: 1, + }, + ]; + workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; + workspaceServersState.servers = []; + rerender({ organizationId: undefined, isAuthLoading: false }); + + await waitFor(() => { + expect(createServerMock).toHaveBeenCalledWith({ + workspaceId: "remote-1", + 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 overwrite conflicting remote servers with the same name", async () => { workspaceQueryState.allWorkspaces = [ { @@ -720,7 +1213,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-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 89436ced5..b5489f51b 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -15,19 +15,16 @@ import { useWorkspaceQueries, useWorkspaceServers, } from "./useWorkspaces"; -import { - deserializeServersFromConvex, - serializeServersForSharing, -} from "@/lib/workspace-serialization"; +import { deserializeServersFromConvex } from "@/lib/workspace-serialization"; import { stableStringifyJson, type WorkspaceClientConfig, } from "@/lib/client-config"; import { getBillingErrorMessage } from "@/lib/billing-entitlements"; import { - buildPersistedServerPayload, + buildCarryForwardServerPayload, buildRemoteServerFromPersistedPayload, - isRemoteServerEquivalent, + isCarryForwardRemoteServerEquivalent, } from "@/lib/persisted-server-payload"; import { useClientConfigStore } from "@/stores/client-config-store"; import { useOrganizationBillingStatus } from "./useOrganizationBilling"; @@ -329,11 +326,8 @@ export function useWorkspaceState({ ); const carryForwardLocalWorkspaces = useMemo( () => - Object.values(appState.workspaces).filter( - (workspace) => - !workspace.sharedWorkspaceId && - Object.keys(workspace.servers).length > 0, - ), + Object.values(appState.workspaces) + .filter((workspace) => Object.keys(workspace.servers).length > 0), [appState.workspaces], ); const migratableLocalWorkspaceCount = migratableLocalWorkspaces.length; @@ -409,6 +403,7 @@ export function useWorkspaceState({ if (!isAuthenticated) { return; } + if (isAuthLoading) return; if (useLocalFallback) return; if (allRemoteWorkspaces === undefined) return; if (allRemoteWorkspaces.length > 0) return; @@ -426,16 +421,36 @@ export function useWorkspaceState({ migrationInFlightRef.current.add(workspace.id); 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 } : {}), }); + + const failedServerNames: string[] = []; + for (const [serverName, server] of Object.entries(workspace.servers)) { + const payload = buildCarryForwardServerPayload(serverName, server); + + try { + await convexCreateServer({ + workspaceId: workspaceId as string, + ...payload, + }); + } catch (error) { + failedServerNames.push(serverName); + logger.error("Failed to migrate local server to Convex", { + workspaceId: workspace.id, + targetWorkspaceId: workspaceId, + serverName, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + dispatch({ type: "UPDATE_WORKSPACE", workspaceId: workspace.id, @@ -446,6 +461,17 @@ export function useWorkspaceState({ : {}), }, }); + + if (appState.activeWorkspaceId === workspace.id) { + setConvexActiveWorkspaceId(workspaceId as string); + } + + if (failedServerNames.length > 0) { + toast.error( + `Could not import some local servers after sign-in: ${failedServerNames.join(", ")}`, + ); + } + logger.info("Migrated workspace to Convex", { name: workspace.name }); } catch (error) { migrationInFlightRef.current.delete(workspace.id); @@ -470,21 +496,25 @@ export function useWorkspaceState({ Promise.all(migratableLocalWorkspaces.map(migrateWorkspace)); }, [ isAuthenticated, + isAuthLoading, useLocalFallback, allRemoteWorkspaces, migratableLocalWorkspaces, migratableLocalWorkspaceCount, convexCreateWorkspace, + convexCreateServer, dispatch, logger, workspaceOrganizationId, canManageBillingForWorkspaceActions, + appState.activeWorkspaceId, ]); useEffect(() => { if (!isAuthenticated) { return; } + if (isAuthLoading) return; if (useLocalFallback) return; if (allRemoteWorkspaces === undefined) return; if (allRemoteWorkspaces.length === 0) return; @@ -496,7 +526,7 @@ export function useWorkspaceState({ if (carryForwardLocalWorkspaces.length === 0) return; const targetOrganizationId = - targetWorkspace.organizationId ?? activeOrganizationId; + targetWorkspace.organizationId ?? workspaceOrganizationId; const carryForwardWorkspace = async ( workspace: Workspace, @@ -517,7 +547,9 @@ export function useWorkspaceState({ const existingRemoteServer = targetServersByName.get(serverName); if (existingRemoteServer) { - if (isRemoteServerEquivalent(server, existingRemoteServer)) { + if ( + isCarryForwardRemoteServerEquivalent(server, existingRemoteServer) + ) { continue; } @@ -525,7 +557,7 @@ export function useWorkspaceState({ continue; } - const payload = buildPersistedServerPayload(serverName, server); + const payload = buildCarryForwardServerPayload(serverName, server); try { const createdServerId = await convexCreateServer({ @@ -556,16 +588,23 @@ export function useWorkspaceState({ } if (conflictNames.length === 0 && failedNames.length === 0) { - dispatch({ - type: "UPDATE_WORKSPACE", - workspaceId: workspace.id, - updates: { - sharedWorkspaceId: convexActiveWorkspaceId, - ...(targetOrganizationId - ? { organizationId: targetOrganizationId } - : {}), - }, - }); + const needsWorkspaceUpdate = + workspace.sharedWorkspaceId !== convexActiveWorkspaceId || + (targetOrganizationId && + workspace.organizationId !== targetOrganizationId); + + if (needsWorkspaceUpdate) { + dispatch({ + type: "UPDATE_WORKSPACE", + workspaceId: workspace.id, + updates: { + sharedWorkspaceId: convexActiveWorkspaceId, + ...(targetOrganizationId + ? { organizationId: targetOrganizationId } + : {}), + }, + }); + } logger.info("Carried forward guest workspace servers", { workspaceId: workspace.id, @@ -607,6 +646,7 @@ export function useWorkspaceState({ })(); }, [ isAuthenticated, + isAuthLoading, useLocalFallback, allRemoteWorkspaces, convexActiveWorkspaceId, @@ -615,7 +655,7 @@ export function useWorkspaceState({ carryForwardLocalWorkspaces, convexCreateServer, dispatch, - activeOrganizationId, + workspaceOrganizationId, logger, ]); 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 index 58cf5e4e9..9b886b068 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { + buildCarryForwardServerPayload, + buildPersistedPayloadFromCarryForwardComparableServer, buildPersistedPayloadFromRemoteServer, buildPersistedServerPayload, - isRemoteServerEquivalent, + isCarryForwardRemoteServerEquivalent, persistedServerPayloadsEqual, } from "../persisted-server-payload"; @@ -104,7 +106,7 @@ describe("persisted-server-payload", () => { true, ); expect( - isRemoteServerEquivalent( + isCarryForwardRemoteServerEquivalent( { config: { url: "https://mcp.linear.app/mcp", @@ -138,4 +140,127 @@ describe("persisted-server-payload", () => { ), ).toBe(true); }); + + 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"); + }); + + it("header-only differences are equivalent in carry-forward", () => { + const result = isCarryForwardRemoteServerEquivalent( + { + config: { + url: "https://mcp.linear.app/mcp", + requestInit: { + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + }, + } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + { + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: undefined, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + } as any, + ); + + expect(result).toBe(true); + }); + + it("normalizes comparable workspace snapshot servers for carry-forward checks", () => { + const payload = buildPersistedPayloadFromCarryForwardComparableServer({ + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + timeout: 30_000, + useOAuth: true, + oauthScopes: "read,write", + clientId: "linear-client", + }); + + expect(payload).toEqual({ + name: "linear", + enabled: true, + transportType: "http", + command: undefined, + args: undefined, + url: "https://mcp.linear.app/mcp", + headers: { + "X-Custom": "1", + }, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }); + }); + + it("same URL but different OAuth config is not equivalent in carry-forward", () => { + const result = isCarryForwardRemoteServerEquivalent( + { + config: { url: "https://mcp.linear.app/mcp" } as any, + enabled: true, + useOAuth: true, + oauthFlowProfile: { + scopes: "read,write", + clientId: "linear-client", + } as any, + }, + { + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: undefined, + timeout: undefined, + useOAuth: false, + oauthScopes: undefined, + clientId: undefined, + } as any, + ); + + expect(result).toBe(false); + }); }); diff --git a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts index 8447a8941..54e57b187 100644 --- a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -87,6 +87,20 @@ export function buildPersistedServerPayload( }; } +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 }; +} + export function buildPersistedPayloadFromRemoteServer( remoteServer: Pick< RemoteServer, @@ -118,6 +132,38 @@ export function buildPersistedPayloadFromRemoteServer( }; } +export interface CarryForwardComparableServer { + name: string; + enabled: boolean; + transportType: "stdio" | "http"; + command?: string; + args?: string[]; + url?: string; + headers?: Record; + timeout?: number; + useOAuth?: boolean; + oauthScopes?: string[] | string; + clientId?: string; +} + +export function buildPersistedPayloadFromCarryForwardComparableServer( + remoteServer: CarryForwardComparableServer, +): PersistedServerPayload { + return { + name: remoteServer.name, + enabled: remoteServer.enabled, + transportType: remoteServer.transportType, + command: remoteServer.command, + args: remoteServer.args ? [...remoteServer.args] : undefined, + url: remoteServer.url, + headers: stripAuthorizationHeader(remoteServer.headers), + timeout: remoteServer.timeout, + useOAuth: remoteServer.useOAuth, + oauthScopes: normalizeScopes(remoteServer.oauthScopes), + clientId: remoteServer.clientId, + }; +} + function normalizePayload( payload: PersistedServerPayload, ): PersistedServerPayload { @@ -145,29 +191,20 @@ export function persistedServerPayloadsEqual( ); } -export function isRemoteServerEquivalent( +export function isCarryForwardRemoteServerEquivalent( localServer: Pick< ServerWithName, "config" | "enabled" | "useOAuth" | "oauthFlowProfile" >, - remoteServer: Pick< - RemoteServer, - | "name" - | "enabled" - | "transportType" - | "command" - | "args" - | "url" - | "headers" - | "timeout" - | "useOAuth" - | "oauthScopes" - | "clientId" - >, + remoteServer: CarryForwardComparableServer, ): boolean { + const localPayload = buildPersistedServerPayload(remoteServer.name, localServer); + const remotePayload = + buildPersistedPayloadFromCarryForwardComparableServer(remoteServer); + return persistedServerPayloadsEqual( - buildPersistedServerPayload(remoteServer.name, localServer), - buildPersistedPayloadFromRemoteServer(remoteServer), + { ...localPayload, headers: undefined }, + { ...remotePayload, headers: undefined }, ); } From e0eedd5fe2039fb35bb4093a1830e62c659895e9 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:07:18 -0700 Subject: [PATCH 3/6] auth headers werer getting stripped not only in migration transition --- .../hooks/__tests__/use-server-state.test.tsx | 126 ++++++++++++++++-- .../persisted-server-payload.test.ts | 28 +++- .../src/lib/persisted-server-payload.ts | 18 +-- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index 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/lib/__tests__/persisted-server-payload.test.ts b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts index 9b886b068..82f44f901 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -9,7 +9,7 @@ import { } from "../persisted-server-payload"; describe("persisted-server-payload", () => { - it("strips Authorization while preserving non-secret headers", () => { + it("preserves Authorization and custom headers for normal persistence", () => { const payload = buildPersistedServerPayload("linear", { config: { url: "https://mcp.linear.app/mcp", @@ -37,6 +37,7 @@ describe("persisted-server-payload", () => { args: undefined, url: "https://mcp.linear.app/mcp", headers: { + Authorization: "Bearer secret", "X-Custom": "1", }, timeout: 30_000, @@ -46,6 +47,28 @@ describe("persisted-server-payload", () => { }); }); + it("preserves Authorization in remote persisted payloads", () => { + const payload = buildPersistedPayloadFromRemoteServer({ + name: "linear", + enabled: true, + transportType: "http", + url: "https://mcp.linear.app/mcp", + headers: { + Authorization: "Bearer secret", + "X-Custom": "1", + }, + timeout: 30_000, + useOAuth: true, + oauthScopes: ["read", "write"], + clientId: "linear-client", + }); + + expect(payload.headers).toEqual({ + Authorization: "Bearer secret", + "X-Custom": "1", + }); + }); + it("excludes runtime-only state from the persisted payload", () => { const payload = buildPersistedServerPayload("demo", { config: { url: "https://example.com/mcp" } as any, @@ -94,6 +117,7 @@ describe("persisted-server-payload", () => { transportType: "http", url: "https://mcp.linear.app/mcp", headers: { + Authorization: "Bearer secret", "X-Custom": "1", }, timeout: undefined, @@ -130,6 +154,7 @@ describe("persisted-server-payload", () => { transportType: "http", url: "https://mcp.linear.app/mcp", headers: { + Authorization: "Bearer secret", "X-Custom": "1", }, timeout: undefined, @@ -228,6 +253,7 @@ describe("persisted-server-payload", () => { args: undefined, url: "https://mcp.linear.app/mcp", headers: { + Authorization: "Bearer secret", "X-Custom": "1", }, timeout: 30_000, diff --git a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts index 54e57b187..0f4a42ec9 100644 --- a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -15,23 +15,19 @@ export interface PersistedServerPayload { clientId?: string; } -function stripAuthorizationHeader( +function normalizeHeaders( headers: Record | undefined, ): Record | undefined { if (!headers) { return undefined; } - const sanitized: Record = {}; + const normalized: Record = {}; for (const [key, value] of Object.entries(headers)) { - if (key.toLowerCase() === "authorization") { - continue; - } - - sanitized[key] = String(value); + normalized[key] = String(value); } - return Object.keys(sanitized).length > 0 ? sanitized : undefined; + return Object.keys(normalized).length > 0 ? normalized : undefined; } function normalizeScopes(scopes: string[] | string | undefined): string[] | undefined { @@ -79,7 +75,7 @@ export function buildPersistedServerPayload( : typeof rawUrl === "string" ? rawUrl : undefined, - headers: stripAuthorizationHeader(rawRequestInit?.headers), + headers: normalizeHeaders(rawRequestInit?.headers), timeout: typeof config.timeout === "number" ? config.timeout : undefined, useOAuth: serverEntry.useOAuth, oauthScopes, @@ -124,7 +120,7 @@ export function buildPersistedPayloadFromRemoteServer( command: remoteServer.command, args: remoteServer.args ? [...remoteServer.args] : undefined, url: remoteServer.url, - headers: stripAuthorizationHeader(remoteServer.headers), + headers: normalizeHeaders(remoteServer.headers), timeout: remoteServer.timeout, useOAuth: remoteServer.useOAuth, oauthScopes: normalizeScopes(remoteServer.oauthScopes), @@ -156,7 +152,7 @@ export function buildPersistedPayloadFromCarryForwardComparableServer( command: remoteServer.command, args: remoteServer.args ? [...remoteServer.args] : undefined, url: remoteServer.url, - headers: stripAuthorizationHeader(remoteServer.headers), + headers: normalizeHeaders(remoteServer.headers), timeout: remoteServer.timeout, useOAuth: remoteServer.useOAuth, oauthScopes: normalizeScopes(remoteServer.oauthScopes), From 35bc9edde45889da86e1a9064fa0d560663b68fc Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:14:37 -0700 Subject: [PATCH 4/6] same name = skip --- mcpjam-inspector/client/src/App.tsx | 27 +- .../src/__tests__/App.hosted-oauth.test.tsx | 46 +- .../client/src/components/LoadingScreen.tsx | 17 +- .../__tests__/use-workspace-state.test.tsx | 902 ++++++++++++++---- .../client/src/hooks/use-app-state.ts | 2 + .../client/src/hooks/use-workspace-state.ts | 374 ++++---- .../client/src/hooks/useWorkspaces.ts | 47 +- .../persisted-server-payload.test.ts | 134 +-- .../src/lib/persisted-server-payload.ts | 49 - 9 files changed, 1034 insertions(+), 564 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index e9e11ae9a..3fb2a5fd4 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -492,6 +492,7 @@ export default function App() { appState, isLoading, isLoadingRemoteWorkspaces, + isWorkspaceBootstrapLoading, workspaceServers, connectedOrConnectingServerConfigs, selectedMCPConfig, @@ -1223,6 +1224,11 @@ export default function App() { !isOAuthCallback && billingEntitlementsUiEnabled !== false && pendingCheckoutIntent !== null; + const shouldShowWorkspaceBootstrapOverlay = + !shouldShowBillingHandoffOverlay && + !isHostedChatRoute && + !isOAuthCallback && + isWorkspaceBootstrapLoading; if (shouldHoldHostedDefaultRouteForAuth) { return ; @@ -1618,13 +1624,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 2e8c2b844..98e7dc7ca 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: () =>
, @@ -1296,6 +1302,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/hooks/__tests__/use-workspace-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-workspace-state.test.tsx index a0a5de13d..7060a0a46 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,7 +7,7 @@ import { useClientConfigStore } from "@/stores/client-config-store"; import type { WorkspaceClientConfig } from "@/lib/client-config"; const { - createServerMock, + bootstrapGuestServerImportMock, createWorkspaceMock, ensureDefaultWorkspaceMock, updateClientConfigMock, @@ -18,7 +18,7 @@ const { organizationBillingStatusState, useOrganizationBillingStatusMock, } = vi.hoisted(() => ({ - createServerMock: vi.fn(), + bootstrapGuestServerImportMock: vi.fn(), createWorkspaceMock: vi.fn(), ensureDefaultWorkspaceMock: vi.fn(), updateClientConfigMock: vi.fn(), @@ -52,24 +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, }), - useServerMutations: () => ({ - createServer: createServerMock, - }), useWorkspaceServers: ({ workspaceId, + enabled = true, }: { workspaceId: string | null; + enabled?: boolean; }) => ({ - servers: workspaceId ? workspaceServersState.servers : undefined, - isLoading: workspaceServersState.isLoading, + servers: + enabled && workspaceId ? workspaceServersState.servers : undefined, + isLoading: enabled ? workspaceServersState.isLoading : false, }), })); @@ -144,9 +156,11 @@ function renderUseWorkspaceState({ ({ organizationId, isAuthLoading, + isAuthenticated, }: { organizationId?: string; isAuthLoading: boolean; + isAuthenticated: boolean; }) => useWorkspaceState({ appState, @@ -161,12 +175,27 @@ 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, }; @@ -181,8 +210,34 @@ 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"); - createServerMock.mockResolvedValue("remote-server-id"); ensureDefaultWorkspaceMock.mockResolvedValue("default-workspace-id"); updateClientConfigMock.mockResolvedValue(undefined); updateWorkspaceMock.mockResolvedValue("remote-workspace-id"); @@ -316,7 +371,7 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("migrates the active local workspace servers into the new signed-in workspace", async () => { + it("bootstraps the active local workspace servers into the signed-in workspace", async () => { const appState = createAppState({ default: createLocalWorkspace("default", { name: "Imported workspace", @@ -355,32 +410,31 @@ describe("useWorkspaceState automatic workspace creation", () => { }); await waitFor(() => { - expect(createWorkspaceMock).toHaveBeenCalledTimes(1); - }); - - expect(createWorkspaceMock).toHaveBeenCalledWith({ - organizationId: "org-migrate", - name: "Imported workspace", - description: "Needs migration", - clientConfig: undefined, - servers: {}, - }); - await waitFor(() => { - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-workspace-id", - 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(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", + }, + ], + }, + ], }); }); + await waitFor(() => { expect(dispatch).toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", @@ -394,10 +448,11 @@ describe("useWorkspaceState automatic workspace creation", () => { 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 migrating local workspaces", async () => { + it("waits for auth loading to finish before bootstrapping local workspace servers", async () => { const appState = createAppState({ default: createLocalWorkspace("default", { name: "Imported workspace", @@ -421,26 +476,32 @@ describe("useWorkspaceState automatic workspace creation", () => { }); await Promise.resolve(); - expect(createWorkspaceMock).not.toHaveBeenCalled(); - expect(createServerMock).not.toHaveBeenCalled(); + expect(bootstrapGuestServerImportMock).not.toHaveBeenCalled(); rerender({ organizationId: undefined, isAuthLoading: false }); await waitFor(() => { - expect(createWorkspaceMock).toHaveBeenCalledTimes(1); - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-workspace-id", - 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, + 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, + }, + ], + }, + ], }); }); }); @@ -495,19 +556,28 @@ describe("useWorkspaceState automatic workspace creation", () => { const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-1", - 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(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", + }, + ], + }, + ], }); }); @@ -522,7 +592,7 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("waits for active workspace server hydration before importing guest servers", async () => { + it("does not wait for active workspace server hydration before bootstrapping guest servers", async () => { workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -555,29 +625,30 @@ describe("useWorkspaceState automatic workspace creation", () => { }), }); - const { rerender } = renderUseWorkspaceState({ appState }); - - await Promise.resolve(); - expect(createServerMock).not.toHaveBeenCalled(); - - workspaceServersState.servers = []; - workspaceServersState.isLoading = false; - rerender({ organizationId: undefined }); + renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-1", - 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, + 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, + }, + ], + }, + ], }); }); }); @@ -654,20 +725,34 @@ describe("useWorkspaceState automatic workspace creation", () => { }), }); - const { dispatch, logger } = renderUseWorkspaceState({ appState }); + const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(logger.info).toHaveBeenCalledWith( - "Carried forward guest workspace servers", - expect.objectContaining({ - workspaceId: "default", - targetWorkspaceId: "remote-1", - serverCount: 1, - }), - ); + 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(createServerMock).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", workspaceId: "default", @@ -724,29 +809,38 @@ describe("useWorkspaceState automatic workspace creation", () => { }); await Promise.resolve(); - expect(createServerMock).not.toHaveBeenCalled(); + expect(bootstrapGuestServerImportMock).not.toHaveBeenCalled(); rerender({ organizationId: undefined, isAuthLoading: false }); await waitFor(() => { - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-1", - 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, + 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 an equivalent remote server as already imported", async () => { + it("treats a same-name remote server as already imported", async () => { workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -758,24 +852,17 @@ describe("useWorkspaceState automatic workspace creation", () => { }, ]; 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-Custom": "1" }, - timeout: 30_000, - useOAuth: true, - oauthScopes: ["read", "write"], - clientId: "linear-client", - createdAt: 1, - updatedAt: 1, - }, - ]; 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", { @@ -812,7 +899,6 @@ describe("useWorkspaceState automatic workspace creation", () => { const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", workspaceId: "default", @@ -823,7 +909,7 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("treats a remote server with different headers as already imported during carry-forward", async () => { + it("silently skips a same-name remote server even when headers differ", async () => { workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -835,24 +921,17 @@ describe("useWorkspaceState automatic workspace creation", () => { }, ]; 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"); + bootstrapGuestServerImportMock.mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: [], + skippedExistingNameServerNames: ["linear"], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); const appState = createAppState({ default: createLocalWorkspace("default", { @@ -889,7 +968,6 @@ describe("useWorkspaceState automatic workspace creation", () => { const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).not.toHaveBeenCalled(); expect(dispatch).toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", workspaceId: "default", @@ -951,19 +1029,28 @@ describe("useWorkspaceState automatic workspace creation", () => { const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-1", - 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(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", + }, + ], + }, + ], }); }); @@ -978,10 +1065,20 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("waits for a default cloud workspace before carrying forward a stale-linked local workspace", async () => { + 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", { @@ -1016,14 +1113,108 @@ describe("useWorkspaceState automatic workspace creation", () => { }), }); - const { dispatch, rerender } = renderUseWorkspaceState({ appState }); + const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(ensureDefaultWorkspaceMock).toHaveBeenCalledTimes(1); + 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", + }, + ], + }, + ], + }); }); - expect(createServerMock).not.toHaveBeenCalled(); + 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", @@ -1036,26 +1227,65 @@ describe("useWorkspaceState automatic workspace creation", () => { ]; workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; workspaceServersState.servers = []; - rerender({ organizationId: undefined, isAuthLoading: false }); + 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(createServerMock).toHaveBeenCalledWith({ - workspaceId: "remote-1", - 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(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", @@ -1066,7 +1296,9 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("does not overwrite conflicting remote servers with the same name", async () => { + it("stops workspace bootstrap loading after 5 seconds if guest import hangs", async () => { + vi.useFakeTimers(); + workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -1078,24 +1310,76 @@ describe("useWorkspaceState automatic workspace creation", () => { }, ]; workspaceQueryState.workspaces = [...workspaceQueryState.allWorkspaces]; - workspaceServersState.servers = [ + 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: "srv-linear", - workspaceId: "remote-1", - name: "linear", - enabled: true, - transportType: "http", - url: "https://different.example.com/mcp", - headers: undefined, - timeout: undefined, - useOAuth: false, - oauthScopes: undefined, - clientId: undefined, + _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", { @@ -1103,9 +1387,9 @@ describe("useWorkspaceState automatic workspace creation", () => { description: "Default workspace", isDefault: true, servers: { - linear: { - name: "linear", - config: { url: "https://mcp.linear.app/mcp" } as any, + demo: { + name: "demo", + config: { url: "https://example.com/mcp" } as any, lastConnectionTime: new Date("2026-01-01T00:00:00.000Z"), connectionStatus: "disconnected", retryCount: 0, @@ -1117,21 +1401,62 @@ describe("useWorkspaceState automatic workspace creation", () => { const { dispatch } = renderUseWorkspaceState({ appState }); await waitFor(() => { - expect(createServerMock).not.toHaveBeenCalled(); - expect(vi.mocked(toast.error)).toHaveBeenCalledWith( - "Some guest servers were not imported because those names already exist: linear", + expect(vi.mocked(toast.warning)).toHaveBeenCalledWith( + "Importing your servers took too long. Opened app without waiting.", ); }); - expect(dispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ - type: "UPDATE_WORKSPACE", - workspaceId: "default", - }), + expect(dispatch).not.toHaveBeenCalledWith({ + type: "UPDATE_WORKSPACE", + workspaceId: "default", + updates: { + sharedWorkspaceId: "remote-1", + }, + }); + }); + + it("stops workspace bootstrap loading if the bootstrap import 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("keeps failed guest imports retryable on a later rerender", async () => { + it("does not retry failed guest imports on a later rerender", async () => { workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -1146,9 +1471,18 @@ describe("useWorkspaceState automatic workspace creation", () => { workspaceServersState.servers = []; localStorage.setItem("convex-active-workspace-id", "remote-1"); - createServerMock + bootstrapGuestServerImportMock .mockRejectedValueOnce(new Error("network down")) - .mockResolvedValueOnce("remote-demo"); + .mockResolvedValueOnce({ + targetWorkspaceId: "remote-1", + targetOrganizationId: undefined, + createdWorkspace: false, + importedServerNames: ["demo"], + skippedExistingNameServerNames: [], + failedServerNames: [], + importedSourceWorkspaceIds: ["default"], + timedOut: false, + }); const appState = createAppState({ default: createLocalWorkspace("default", { @@ -1171,7 +1505,7 @@ describe("useWorkspaceState automatic workspace creation", () => { await waitFor(() => { expect(vi.mocked(toast.error)).toHaveBeenCalledWith( - "Could not import some guest servers after sign-in: demo", + "Could not import guest servers after sign-in", ); }); @@ -1186,8 +1520,8 @@ describe("useWorkspaceState automatic workspace creation", () => { rerender({ organizationId: undefined }); await waitFor(() => { - expect(createServerMock).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith({ + expect(bootstrapGuestServerImportMock).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalledWith({ type: "UPDATE_WORKSPACE", workspaceId: "default", updates: { @@ -1197,6 +1531,156 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + 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("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("treats the empty synthetic default as ensure-default only, not a migration candidate", async () => { const appState = createAppState({ default: createSyntheticDefaultWorkspace(), 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-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index b5489f51b..edaa0a2ba 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -9,8 +9,7 @@ import { import { toast } from "sonner"; import type { AppAction, AppState, Workspace } from "@/state/app-types"; import { - type RemoteServer, - useServerMutations, + type BootstrapGuestSourceWorkspace, useWorkspaceMutations, useWorkspaceQueries, useWorkspaceServers, @@ -23,13 +22,12 @@ import { import { getBillingErrorMessage } from "@/lib/billing-entitlements"; import { buildCarryForwardServerPayload, - buildRemoteServerFromPersistedPayload, - isCarryForwardRemoteServerEquivalent, } 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 WORKSPACE_BOOTSTRAP_TIMEOUT_MS = 5000; function stringifyWorkspaceClientConfig( clientConfig: WorkspaceClientConfig | undefined, @@ -83,6 +81,46 @@ 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); + const [isWorkspaceBootstrapLoading, setIsWorkspaceBootstrapLoading] = + useState(false); + const [hasCompletedBootstrapImport, setHasCompletedBootstrapImport] = + useState(false); + 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; + const shouldPauseRemoteBootstrapQueries = + isAuthenticated && + !useLocalFallback && + hasCarryForwardCandidates && + !hasCompletedBootstrapImport; const { allWorkspaces: allRemoteWorkspaces, workspaces: remoteWorkspaces, @@ -90,9 +128,11 @@ export function useWorkspaceState({ } = useWorkspaceQueries({ isAuthenticated, organizationId: workspaceOrganizationId, + enabled: !shouldPauseRemoteBootstrapQueries, }); const { createWorkspace: convexCreateWorkspace, + bootstrapGuestServerImport: convexBootstrapGuestServerImport, ensureDefaultWorkspace: convexEnsureDefaultWorkspace, updateWorkspace: convexUpdateWorkspace, updateClientConfig: convexUpdateClientConfig, @@ -102,30 +142,25 @@ export function useWorkspaceState({ workspaceOrganizationId ?? null, { enabled: isAuthenticated }, ); - const { createServer: convexCreateServer } = useServerMutations(); - - 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 migrationInFlightRef = useRef(new Set()); - const carryForwardInFlightRef = useRef(new Set()); + const bootstrapMutationInFlightRef = useRef(false); + const carryForwardAbortRef = useRef(false); + const carryForwardCompletedRef = useRef(false); 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 pendingClientConfigSyncRef = useRef( null, ); @@ -145,6 +180,22 @@ export function useWorkspaceState({ } }, []); + const clearWorkspaceBootstrapTimeout = useCallback(() => { + if (workspaceBootstrapTimeoutRef.current) { + clearTimeout(workspaceBootstrapTimeoutRef.current); + workspaceBootstrapTimeoutRef.current = null; + } + }, []); + + const finishWorkspaceBootstrap = useCallback(() => { + carryForwardAbortRef.current = true; + carryForwardCompletedRef.current = true; + setHasCompletedBootstrapImport(true); + clearWorkspaceBootstrapTimeout(); + bootstrapMutationInFlightRef.current = false; + setIsWorkspaceBootstrapLoading(false); + }, [clearWorkspaceBootstrapTimeout]); + useEffect(() => { if (!isAuthenticated) { setUseLocalFallback(false); @@ -203,6 +254,12 @@ export function useWorkspaceState({ }; }, [clearPendingClientConfigSync]); + useEffect(() => { + return () => { + clearWorkspaceBootstrapTimeout(); + }; + }, [clearWorkspaceBootstrapTimeout]); + const isLoadingRemoteWorkspaces = (isAuthenticated && !useLocalFallback && @@ -324,12 +381,6 @@ export function useWorkspaceState({ ), [appState.workspaces], ); - const carryForwardLocalWorkspaces = useMemo( - () => - Object.values(appState.workspaces) - .filter((workspace) => Object.keys(workspace.servers).length > 0), - [appState.workspaces], - ); const migratableLocalWorkspaceCount = migratableLocalWorkspaces.length; const hasAnyRemoteWorkspaces = (allRemoteWorkspaces?.length ?? 0) > 0; const hasCurrentOrganizationWorkspaces = (remoteWorkspaces?.length ?? 0) > 0; @@ -372,20 +423,65 @@ export function useWorkspaceState({ useEffect(() => { if (!isAuthenticated || useLocalFallback) { migrationInFlightRef.current.clear(); - carryForwardInFlightRef.current.clear(); + bootstrapMutationInFlightRef.current = false; + carryForwardAbortRef.current = false; + carryForwardCompletedRef.current = false; + setHasCompletedBootstrapImport(false); 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(); + clearWorkspaceBootstrapTimeout(); + setIsWorkspaceBootstrapLoading(false); } - }, [isAuthenticated, useLocalFallback]); + }, [clearWorkspaceBootstrapTimeout, isAuthenticated, useLocalFallback]); + + useEffect(() => { + const shouldRunBootstrapImport = + isAuthenticated && + !isAuthLoading && + !useLocalFallback && + hasCarryForwardCandidates && + !hasCompletedBootstrapImport; + + if (!shouldRunBootstrapImport) { + clearWorkspaceBootstrapTimeout(); + setIsWorkspaceBootstrapLoading(false); + return; + } + + carryForwardAbortRef.current = false; + setIsWorkspaceBootstrapLoading(true); + if (!workspaceBootstrapTimeoutRef.current) { + workspaceBootstrapTimeoutRef.current = setTimeout(() => { + workspaceBootstrapTimeoutRef.current = null; + logger.warn("Workspace bootstrap import timed out", { + timeoutMs: WORKSPACE_BOOTSTRAP_TIMEOUT_MS, + }); + toast.warning( + "Importing your servers took too long. Opened app without waiting.", + ); + finishWorkspaceBootstrap(); + }, WORKSPACE_BOOTSTRAP_TIMEOUT_MS); + } + }, [ + clearWorkspaceBootstrapTimeout, + finishWorkspaceBootstrap, + hasCompletedBootstrapImport, + hasCarryForwardCandidates, + isAuthenticated, + isAuthLoading, + logger, + useLocalFallback, + ]); useEffect(() => { if ( !isAuthenticated || useLocalFallback || + hasCarryForwardCandidates || allRemoteWorkspaces === undefined || allRemoteWorkspaces.length > 0 || migratableLocalWorkspaceCount === 0 @@ -395,6 +491,7 @@ export function useWorkspaceState({ }, [ isAuthenticated, useLocalFallback, + hasCarryForwardCandidates, allRemoteWorkspaces, migratableLocalWorkspaceCount, ]); @@ -403,6 +500,8 @@ export function useWorkspaceState({ if (!isAuthenticated) { return; } + if (hasCarryForwardCandidates) return; + if (carryForwardCompletedRef.current) return; if (isAuthLoading) return; if (useLocalFallback) return; if (allRemoteWorkspaces === undefined) return; @@ -414,6 +513,9 @@ export function useWorkspaceState({ }); const migrateWorkspace = async (workspace: Workspace) => { + if (carryForwardAbortRef.current) { + return; + } if (migrationInFlightRef.current.has(workspace.id)) { return; } @@ -430,25 +532,8 @@ export function useWorkspaceState({ ? { organizationId: workspaceOrganizationId } : {}), }); - - const failedServerNames: string[] = []; - for (const [serverName, server] of Object.entries(workspace.servers)) { - const payload = buildCarryForwardServerPayload(serverName, server); - - try { - await convexCreateServer({ - workspaceId: workspaceId as string, - ...payload, - }); - } catch (error) { - failedServerNames.push(serverName); - logger.error("Failed to migrate local server to Convex", { - workspaceId: workspace.id, - targetWorkspaceId: workspaceId, - serverName, - error: error instanceof Error ? error.message : "Unknown error", - }); - } + if (carryForwardAbortRef.current) { + return; } dispatch({ @@ -466,12 +551,6 @@ export function useWorkspaceState({ setConvexActiveWorkspaceId(workspaceId as string); } - if (failedServerNames.length > 0) { - toast.error( - `Could not import some local servers after sign-in: ${failedServerNames.join(", ")}`, - ); - } - logger.info("Migrated workspace to Convex", { name: workspace.name }); } catch (error) { migrationInFlightRef.current.delete(workspace.id); @@ -490,6 +569,9 @@ export function useWorkspaceState({ name: workspace.name, error: error instanceof Error ? error.message : "Unknown error", }); + if (hasCarryForwardCandidates) { + finishWorkspaceBootstrap(); + } } }; @@ -498,16 +580,18 @@ export function useWorkspaceState({ isAuthenticated, isAuthLoading, useLocalFallback, + hasCarryForwardCandidates, allRemoteWorkspaces, migratableLocalWorkspaces, migratableLocalWorkspaceCount, convexCreateWorkspace, - convexCreateServer, dispatch, logger, workspaceOrganizationId, canManageBillingForWorkspaceActions, appState.activeWorkspaceId, + finishWorkspaceBootstrap, + hasCarryForwardCandidates, ]); useEffect(() => { @@ -516,145 +600,100 @@ export function useWorkspaceState({ } if (isAuthLoading) return; if (useLocalFallback) return; - if (allRemoteWorkspaces === undefined) return; - if (allRemoteWorkspaces.length === 0) return; - if (!convexActiveWorkspaceId) return; - - const targetWorkspace = convexWorkspaces[convexActiveWorkspaceId]; - if (!targetWorkspace) return; - if (activeWorkspaceServersFlat === undefined) return; - if (carryForwardLocalWorkspaces.length === 0) return; - - const targetOrganizationId = - targetWorkspace.organizationId ?? workspaceOrganizationId; - - const carryForwardWorkspace = async ( - workspace: Workspace, - targetServersByName: Map, - ) => { - const inFlightKey = `${convexActiveWorkspaceId}:${workspace.id}`; - if (carryForwardInFlightRef.current.has(inFlightKey)) { - return; - } + if (!hasCarryForwardCandidates) return; + if (hasCompletedBootstrapImport) return; + if (bootstrapMutationInFlightRef.current) return; - carryForwardInFlightRef.current.add(inFlightKey); - - const conflictNames: string[] = []; - const failedNames: string[] = []; + bootstrapMutationInFlightRef.current = true; + void (async () => { try { - for (const [serverName, server] of Object.entries(workspace.servers)) { - const existingRemoteServer = targetServersByName.get(serverName); - - if (existingRemoteServer) { - if ( - isCarryForwardRemoteServerEquivalent(server, existingRemoteServer) - ) { - continue; - } - - conflictNames.push(serverName); - continue; - } + const result = await convexBootstrapGuestServerImport({ + ...(workspaceOrganizationId + ? { organizationId: workspaceOrganizationId } + : {}), + ...(convexActiveWorkspaceId + ? { preferredWorkspaceId: convexActiveWorkspaceId } + : {}), + sourceWorkspaces: bootstrapGuestSourceWorkspaces, + }); - const payload = buildCarryForwardServerPayload(serverName, server); - - try { - const createdServerId = await convexCreateServer({ - workspaceId: convexActiveWorkspaceId, - ...payload, - }); - - targetServersByName.set( - serverName, - buildRemoteServerFromPersistedPayload({ - payload, - workspaceId: convexActiveWorkspaceId, - serverId: - typeof createdServerId === "string" - ? createdServerId - : undefined, - }), - ); - } catch (error) { - failedNames.push(serverName); - logger.error("Failed to carry forward guest server", { - workspaceId: workspace.id, - targetWorkspaceId: convexActiveWorkspaceId, - serverName, - error: error instanceof Error ? error.message : "Unknown error", - }); - } + if (carryForwardAbortRef.current) { + return; } - if (conflictNames.length === 0 && failedNames.length === 0) { - const needsWorkspaceUpdate = - workspace.sharedWorkspaceId !== convexActiveWorkspaceId || - (targetOrganizationId && - workspace.organizationId !== targetOrganizationId); - - if (needsWorkspaceUpdate) { - dispatch({ - type: "UPDATE_WORKSPACE", - workspaceId: workspace.id, - updates: { - sharedWorkspaceId: convexActiveWorkspaceId, - ...(targetOrganizationId - ? { organizationId: targetOrganizationId } - : {}), - }, - }); - } + const targetOrganizationId = + result.targetOrganizationId ?? workspaceOrganizationId; - logger.info("Carried forward guest workspace servers", { - workspaceId: workspace.id, - targetWorkspaceId: convexActiveWorkspaceId, - serverCount: Object.keys(workspace.servers).length, + 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 } + : {}), + }, }); - return; } - if (conflictNames.length > 0) { - logger.warn("Guest workspace server carry-forward conflicts", { - workspaceId: workspace.id, - targetWorkspaceId: convexActiveWorkspaceId, - serverNames: conflictNames, + if (result.failedServerNames.length > 0) { + logger.error("Failed to carry forward some guest servers", { + targetWorkspaceId: result.targetWorkspaceId, + serverNames: result.failedServerNames, }); toast.error( - `Some guest servers were not imported because those names already exist: ${conflictNames.join(", ")}`, + `Could not import some guest servers after sign-in: ${result.failedServerNames.join(", ")}`, ); } - if (failedNames.length > 0) { - toast.error( - `Could not import some guest servers after sign-in: ${failedNames.join(", ")}`, + if (result.timedOut) { + logger.warn("Workspace bootstrap import timed out in Convex", { + targetWorkspaceId: result.targetWorkspaceId, + }); + toast.warning( + "Importing your servers took too long. Opened app without waiting.", ); } - } finally { - carryForwardInFlightRef.current.delete(inFlightKey); - } - }; - - void (async () => { - const targetServersByName = new Map( - activeWorkspaceServersFlat.map((server) => [server.name, server]), - ); - for (const workspace of carryForwardLocalWorkspaces) { - await carryForwardWorkspace(workspace, targetServersByName); + 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 (!carryForwardAbortRef.current) { + 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(); } })(); }, [ isAuthenticated, isAuthLoading, useLocalFallback, - allRemoteWorkspaces, + hasCarryForwardCandidates, + hasCompletedBootstrapImport, + bootstrapGuestSourceWorkspaces, convexActiveWorkspaceId, - convexWorkspaces, - activeWorkspaceServersFlat, - carryForwardLocalWorkspaces, - convexCreateServer, + convexBootstrapGuestServerImport, dispatch, + finishWorkspaceBootstrap, workspaceOrganizationId, logger, ]); @@ -663,6 +702,8 @@ export function useWorkspaceState({ if (!isAuthenticated) { return; } + if (hasCarryForwardCandidates) return; + if (carryForwardCompletedRef.current) return; if (useLocalFallback) return; if (remoteWorkspaces === undefined) return; if (hasCurrentOrganizationWorkspaces) return; @@ -692,15 +733,21 @@ export function useWorkspaceState({ organizationId: workspaceOrganizationId, error: error instanceof Error ? error.message : "Unknown error", }); + if (hasCarryForwardCandidates) { + finishWorkspaceBootstrap(); + } }); }, [ isAuthenticated, useLocalFallback, + hasCarryForwardCandidates, remoteWorkspaces, hasCurrentOrganizationWorkspaces, hasAnyRemoteWorkspaces, migratableLocalWorkspaceCount, convexEnsureDefaultWorkspace, + finishWorkspaceBootstrap, + hasCarryForwardCandidates, workspaceOrganizationId, logger, ]); @@ -1084,6 +1131,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..24bc43624 100644 --- a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts +++ b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts @@ -49,6 +49,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 +158,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 +234,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 +260,7 @@ export function useWorkspaceMutations() { return { createWorkspace, + bootstrapGuestServerImport, ensureDefaultWorkspace, updateWorkspace, updateClientConfig, @@ -252,16 +288,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 index 82f44f901..0d4b5f23a 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "vitest"; import { buildCarryForwardServerPayload, - buildPersistedPayloadFromCarryForwardComparableServer, buildPersistedPayloadFromRemoteServer, buildPersistedServerPayload, - isCarryForwardRemoteServerEquivalent, persistedServerPayloadsEqual, } from "../persisted-server-payload"; @@ -93,7 +91,7 @@ describe("persisted-server-payload", () => { }); }); - it("treats matching sanitized local and remote servers as equivalent", () => { + it("treats matching local and remote payloads as equivalent", () => { const localPayload = buildPersistedServerPayload("linear", { config: { url: "https://mcp.linear.app/mcp", @@ -129,41 +127,6 @@ describe("persisted-server-payload", () => { expect(persistedServerPayloadsEqual(localPayload, remotePayload)).toBe( true, ); - expect( - isCarryForwardRemoteServerEquivalent( - { - config: { - url: "https://mcp.linear.app/mcp", - requestInit: { - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - }, - } as any, - enabled: true, - useOAuth: true, - oauthFlowProfile: { - scopes: "read,write", - clientId: "linear-client", - } as any, - }, - { - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - timeout: undefined, - useOAuth: true, - oauthScopes: ["read", "write"], - clientId: "linear-client", - } as any, - ), - ).toBe(true); }); it("carry-forward payload omits all headers including sensitive ones", () => { @@ -194,99 +157,4 @@ describe("persisted-server-payload", () => { expect(payload.clientId).toBe("linear-client"); }); - it("header-only differences are equivalent in carry-forward", () => { - const result = isCarryForwardRemoteServerEquivalent( - { - config: { - url: "https://mcp.linear.app/mcp", - requestInit: { - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - }, - } as any, - enabled: true, - useOAuth: true, - oauthFlowProfile: { - scopes: "read,write", - clientId: "linear-client", - } as any, - }, - { - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: undefined, - timeout: undefined, - useOAuth: true, - oauthScopes: ["read", "write"], - clientId: "linear-client", - } as any, - ); - - expect(result).toBe(true); - }); - - it("normalizes comparable workspace snapshot servers for carry-forward checks", () => { - const payload = buildPersistedPayloadFromCarryForwardComparableServer({ - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - timeout: 30_000, - useOAuth: true, - oauthScopes: "read,write", - clientId: "linear-client", - }); - - 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("same URL but different OAuth config is not equivalent in carry-forward", () => { - const result = isCarryForwardRemoteServerEquivalent( - { - config: { url: "https://mcp.linear.app/mcp" } as any, - enabled: true, - useOAuth: true, - oauthFlowProfile: { - scopes: "read,write", - clientId: "linear-client", - } as any, - }, - { - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: undefined, - timeout: undefined, - useOAuth: false, - oauthScopes: undefined, - clientId: undefined, - } as any, - ); - - expect(result).toBe(false); - }); }); diff --git a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts index 0f4a42ec9..3db22d735 100644 --- a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -128,38 +128,6 @@ export function buildPersistedPayloadFromRemoteServer( }; } -export interface CarryForwardComparableServer { - name: string; - enabled: boolean; - transportType: "stdio" | "http"; - command?: string; - args?: string[]; - url?: string; - headers?: Record; - timeout?: number; - useOAuth?: boolean; - oauthScopes?: string[] | string; - clientId?: string; -} - -export function buildPersistedPayloadFromCarryForwardComparableServer( - remoteServer: CarryForwardComparableServer, -): PersistedServerPayload { - return { - name: remoteServer.name, - enabled: remoteServer.enabled, - transportType: remoteServer.transportType, - command: remoteServer.command, - args: remoteServer.args ? [...remoteServer.args] : undefined, - url: remoteServer.url, - headers: normalizeHeaders(remoteServer.headers), - timeout: remoteServer.timeout, - useOAuth: remoteServer.useOAuth, - oauthScopes: normalizeScopes(remoteServer.oauthScopes), - clientId: remoteServer.clientId, - }; -} - function normalizePayload( payload: PersistedServerPayload, ): PersistedServerPayload { @@ -187,23 +155,6 @@ export function persistedServerPayloadsEqual( ); } -export function isCarryForwardRemoteServerEquivalent( - localServer: Pick< - ServerWithName, - "config" | "enabled" | "useOAuth" | "oauthFlowProfile" - >, - remoteServer: CarryForwardComparableServer, -): boolean { - const localPayload = buildPersistedServerPayload(remoteServer.name, localServer); - const remotePayload = - buildPersistedPayloadFromCarryForwardComparableServer(remoteServer); - - return persistedServerPayloadsEqual( - { ...localPayload, headers: undefined }, - { ...remotePayload, headers: undefined }, - ); -} - export function buildRemoteServerFromPersistedPayload(args: { payload: PersistedServerPayload; workspaceId: string; From 9901f10d69482665cca928bd3dc31f1969cfc4f9 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:09:06 -0700 Subject: [PATCH 5/6] stale results are ignored --- .../__tests__/use-workspace-state.test.tsx | 357 ++++++++++++++++++ .../client/src/hooks/use-workspace-state.ts | 156 +++++--- .../persisted-server-payload.test.ts | 63 ---- .../src/lib/persisted-server-payload.ts | 91 ----- 4 files changed, 468 insertions(+), 199 deletions(-) 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 7060a0a46..271d548e9 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 @@ -132,6 +132,17 @@ 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, @@ -1635,6 +1646,202 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + it("ignores a stale bootstrap import 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 bootstrap 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 = [ { @@ -1681,6 +1888,156 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + 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(), diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index edaa0a2ba..579ff0b17 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -14,7 +14,10 @@ import { useWorkspaceQueries, useWorkspaceServers, } from "./useWorkspaces"; -import { deserializeServersFromConvex } from "@/lib/workspace-serialization"; +import { + deserializeServersFromConvex, + serializeServersForSharing, +} from "@/lib/workspace-serialization"; import { stableStringifyJson, type WorkspaceClientConfig, @@ -49,6 +52,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" && @@ -90,10 +97,20 @@ export function useWorkspaceState({ 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( @@ -116,6 +133,8 @@ export function useWorkspaceState({ [carryForwardLocalWorkspaces], ); const hasCarryForwardCandidates = bootstrapGuestSourceWorkspaces.length > 0; + // Hold off Convex queries until the bootstrap import finishes, preventing + // the UI from briefly showing an empty or partial workspace. const shouldPauseRemoteBootstrapQueries = isAuthenticated && !useLocalFallback && @@ -150,10 +169,11 @@ export function useWorkspaceState({ enabled: !shouldPauseRemoteBootstrapQueries, }); + const operationGenerationRef = useRef(0); + const operationContextKeyRef = useRef(operationContextKey); const migrationInFlightRef = useRef(new Set()); - const bootstrapMutationInFlightRef = useRef(false); - const carryForwardAbortRef = useRef(false); - const carryForwardCompletedRef = useRef(false); + const bootstrapMutationInFlightGenerationRef = useRef(null); + const completedBootstrapGenerationRef = useRef(null); const ensureDefaultInFlightRef = useRef(new Set()); const ensureDefaultCompletedRef = useRef(new Set()); const migrationErrorNotifiedRef = useRef(new Set()); @@ -161,6 +181,7 @@ export function useWorkspaceState({ const workspaceBootstrapTimeoutRef = useRef | null>( null, ); + const workspaceBootstrapTimeoutGenerationRef = useRef(null); const pendingClientConfigSyncRef = useRef( null, ); @@ -185,17 +206,49 @@ export function useWorkspaceState({ 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 finishWorkspaceBootstrap = useCallback(() => { - carryForwardAbortRef.current = true; - carryForwardCompletedRef.current = true; - setHasCompletedBootstrapImport(true); + const resetOperationGeneration = useCallback(() => { + operationGenerationRef.current += 1; + migrationInFlightRef.current.clear(); + bootstrapMutationInFlightGenerationRef.current = null; + completedBootstrapGenerationRef.current = null; + migrationErrorNotifiedRef.current.clear(); clearWorkspaceBootstrapTimeout(); - bootstrapMutationInFlightRef.current = false; + 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); @@ -421,24 +474,14 @@ export function useWorkspaceState({ }, [convexActiveWorkspaceId]); useEffect(() => { - if (!isAuthenticated || useLocalFallback) { - migrationInFlightRef.current.clear(); - bootstrapMutationInFlightRef.current = false; - carryForwardAbortRef.current = false; - carryForwardCompletedRef.current = false; - setHasCompletedBootstrapImport(false); - 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(); - clearWorkspaceBootstrapTimeout(); - setIsWorkspaceBootstrapLoading(false); + if (operationContextKeyRef.current !== operationContextKey) { + operationContextKeyRef.current = operationContextKey; + resetOperationGeneration(); } - }, [clearWorkspaceBootstrapTimeout, isAuthenticated, useLocalFallback]); + }, [operationContextKey, resetOperationGeneration]); useEffect(() => { + const currentGeneration = operationGenerationRef.current; const shouldRunBootstrapImport = isAuthenticated && !isAuthLoading && @@ -452,21 +495,27 @@ export function useWorkspaceState({ return; } - carryForwardAbortRef.current = false; setIsWorkspaceBootstrapLoading(true); - if (!workspaceBootstrapTimeoutRef.current) { + if (workspaceBootstrapTimeoutGenerationRef.current !== currentGeneration) { + clearWorkspaceBootstrapTimeout(); + workspaceBootstrapTimeoutGenerationRef.current = currentGeneration; workspaceBootstrapTimeoutRef.current = setTimeout(() => { + if (!canContinueBootstrapGeneration(currentGeneration)) { + return; + } workspaceBootstrapTimeoutRef.current = null; + workspaceBootstrapTimeoutGenerationRef.current = null; logger.warn("Workspace bootstrap import timed out", { timeoutMs: WORKSPACE_BOOTSTRAP_TIMEOUT_MS, }); toast.warning( "Importing your servers took too long. Opened app without waiting.", ); - finishWorkspaceBootstrap(); + finishWorkspaceBootstrap(currentGeneration); }, WORKSPACE_BOOTSTRAP_TIMEOUT_MS); } }, [ + canContinueBootstrapGeneration, clearWorkspaceBootstrapTimeout, finishWorkspaceBootstrap, hasCompletedBootstrapImport, @@ -501,7 +550,8 @@ export function useWorkspaceState({ return; } if (hasCarryForwardCandidates) return; - if (carryForwardCompletedRef.current) return; + const currentGeneration = operationGenerationRef.current; + if (completedBootstrapGenerationRef.current === currentGeneration) return; if (isAuthLoading) return; if (useLocalFallback) return; if (allRemoteWorkspaces === undefined) return; @@ -513,14 +563,15 @@ export function useWorkspaceState({ }); const migrateWorkspace = async (workspace: Workspace) => { - if (carryForwardAbortRef.current) { + if (!isCurrentOperationGeneration(currentGeneration)) { return; } - if (migrationInFlightRef.current.has(workspace.id)) { + const inFlightKey = buildGenerationKey(currentGeneration, workspace.id); + if (migrationInFlightRef.current.has(inFlightKey)) { return; } - migrationInFlightRef.current.add(workspace.id); + migrationInFlightRef.current.add(inFlightKey); try { const workspaceId = await convexCreateWorkspace({ @@ -532,7 +583,7 @@ export function useWorkspaceState({ ? { organizationId: workspaceOrganizationId } : {}), }); - if (carryForwardAbortRef.current) { + if (!isCurrentOperationGeneration(currentGeneration)) { return; } @@ -553,8 +604,14 @@ export function useWorkspaceState({ 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( @@ -570,7 +627,7 @@ export function useWorkspaceState({ error: error instanceof Error ? error.message : "Unknown error", }); if (hasCarryForwardCandidates) { - finishWorkspaceBootstrap(); + finishWorkspaceBootstrap(currentGeneration); } } }; @@ -591,20 +648,23 @@ export function useWorkspaceState({ canManageBillingForWorkspaceActions, appState.activeWorkspaceId, finishWorkspaceBootstrap, - hasCarryForwardCandidates, + isCurrentOperationGeneration, ]); useEffect(() => { if (!isAuthenticated) { return; } + const currentGeneration = operationGenerationRef.current; if (isAuthLoading) return; if (useLocalFallback) return; if (!hasCarryForwardCandidates) return; if (hasCompletedBootstrapImport) return; - if (bootstrapMutationInFlightRef.current) return; + if (bootstrapMutationInFlightGenerationRef.current === currentGeneration) { + return; + } - bootstrapMutationInFlightRef.current = true; + bootstrapMutationInFlightGenerationRef.current = currentGeneration; void (async () => { try { @@ -618,7 +678,7 @@ export function useWorkspaceState({ sourceWorkspaces: bootstrapGuestSourceWorkspaces, }); - if (carryForwardAbortRef.current) { + if (!canContinueBootstrapGeneration(currentGeneration)) { return; } @@ -673,17 +733,18 @@ export function useWorkspaceState({ timedOut: result.timedOut, }); } catch (error) { - if (!carryForwardAbortRef.current) { + 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(); + finishWorkspaceBootstrap(currentGeneration); } })(); }, [ + canContinueBootstrapGeneration, isAuthenticated, isAuthLoading, useLocalFallback, @@ -703,7 +764,8 @@ export function useWorkspaceState({ return; } if (hasCarryForwardCandidates) return; - if (carryForwardCompletedRef.current) return; + const currentGeneration = operationGenerationRef.current; + if (completedBootstrapGenerationRef.current === currentGeneration) return; if (useLocalFallback) return; if (remoteWorkspaces === undefined) return; if (hasCurrentOrganizationWorkspaces) return; @@ -724,17 +786,21 @@ 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(); + finishWorkspaceBootstrap(currentGeneration); } }); }, [ @@ -747,7 +813,7 @@ export function useWorkspaceState({ migratableLocalWorkspaceCount, convexEnsureDefaultWorkspace, finishWorkspaceBootstrap, - hasCarryForwardCandidates, + isCurrentOperationGeneration, workspaceOrganizationId, logger, ]); 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 index 0d4b5f23a..1869f1687 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/persisted-server-payload.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildCarryForwardServerPayload, - buildPersistedPayloadFromRemoteServer, buildPersistedServerPayload, - persistedServerPayloadsEqual, } from "../persisted-server-payload"; describe("persisted-server-payload", () => { @@ -45,28 +43,6 @@ describe("persisted-server-payload", () => { }); }); - it("preserves Authorization in remote persisted payloads", () => { - const payload = buildPersistedPayloadFromRemoteServer({ - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - timeout: 30_000, - useOAuth: true, - oauthScopes: ["read", "write"], - clientId: "linear-client", - }); - - expect(payload.headers).toEqual({ - Authorization: "Bearer secret", - "X-Custom": "1", - }); - }); - it("excludes runtime-only state from the persisted payload", () => { const payload = buildPersistedServerPayload("demo", { config: { url: "https://example.com/mcp" } as any, @@ -91,44 +67,6 @@ describe("persisted-server-payload", () => { }); }); - it("treats matching local and remote payloads as equivalent", () => { - const localPayload = buildPersistedServerPayload("linear", { - config: { - url: "https://mcp.linear.app/mcp", - requestInit: { - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - }, - } as any, - enabled: true, - useOAuth: true, - oauthFlowProfile: { - scopes: "read,write", - clientId: "linear-client", - } as any, - }); - const remotePayload = buildPersistedPayloadFromRemoteServer({ - name: "linear", - enabled: true, - transportType: "http", - url: "https://mcp.linear.app/mcp", - headers: { - Authorization: "Bearer secret", - "X-Custom": "1", - }, - timeout: undefined, - useOAuth: true, - oauthScopes: ["read", "write"], - clientId: "linear-client", - }); - - expect(persistedServerPayloadsEqual(localPayload, remotePayload)).toBe( - true, - ); - }); - it("carry-forward payload omits all headers including sensitive ones", () => { const payload = buildCarryForwardServerPayload("linear", { config: { @@ -156,5 +94,4 @@ describe("persisted-server-payload", () => { 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 index 3db22d735..8300d7c27 100644 --- a/mcpjam-inspector/client/src/lib/persisted-server-payload.ts +++ b/mcpjam-inspector/client/src/lib/persisted-server-payload.ts @@ -1,5 +1,4 @@ import type { ServerWithName } from "@/state/app-types"; -import type { RemoteServer } from "@/hooks/useWorkspaces"; export interface PersistedServerPayload { name: string; @@ -96,93 +95,3 @@ export function buildCarryForwardServerPayload( // uploaded into workspace data during guest -> signed-in carry-forward. return { ...payload, headers: undefined }; } - -export function buildPersistedPayloadFromRemoteServer( - remoteServer: Pick< - RemoteServer, - | "name" - | "enabled" - | "transportType" - | "command" - | "args" - | "url" - | "headers" - | "timeout" - | "useOAuth" - | "oauthScopes" - | "clientId" - >, -): PersistedServerPayload { - return { - name: remoteServer.name, - enabled: remoteServer.enabled, - transportType: remoteServer.transportType, - command: remoteServer.command, - args: remoteServer.args ? [...remoteServer.args] : undefined, - url: remoteServer.url, - headers: normalizeHeaders(remoteServer.headers), - timeout: remoteServer.timeout, - useOAuth: remoteServer.useOAuth, - oauthScopes: normalizeScopes(remoteServer.oauthScopes), - clientId: remoteServer.clientId, - }; -} - -function normalizePayload( - payload: PersistedServerPayload, -): PersistedServerPayload { - return { - ...payload, - args: payload.args ? [...payload.args] : undefined, - headers: payload.headers - ? Object.fromEntries( - Object.entries(payload.headers).sort(([left], [right]) => - left.localeCompare(right), - ), - ) - : undefined, - oauthScopes: payload.oauthScopes ? [...payload.oauthScopes] : undefined, - }; -} - -export function persistedServerPayloadsEqual( - left: PersistedServerPayload, - right: PersistedServerPayload, -): boolean { - return ( - JSON.stringify(normalizePayload(left)) === - JSON.stringify(normalizePayload(right)) - ); -} - -export function buildRemoteServerFromPersistedPayload(args: { - payload: PersistedServerPayload; - workspaceId: string; - serverId?: string; - createdAt?: number; - updatedAt?: number; -}): RemoteServer { - const now = Date.now(); - - return { - _id: args.serverId ?? `persisted:${args.payload.name}`, - workspaceId: args.workspaceId, - name: args.payload.name, - enabled: args.payload.enabled, - transportType: args.payload.transportType, - command: args.payload.command, - args: args.payload.args ? [...args.payload.args] : undefined, - url: args.payload.url, - headers: args.payload.headers - ? { ...args.payload.headers } - : undefined, - timeout: args.payload.timeout, - useOAuth: args.payload.useOAuth, - oauthScopes: args.payload.oauthScopes - ? [...args.payload.oauthScopes] - : undefined, - clientId: args.payload.clientId, - createdAt: args.createdAt ?? now, - updatedAt: args.updatedAt ?? now, - }; -} From b4f9419555b4bd049937e0c5493731916d6696a8 Mon Sep 17 00:00:00 2001 From: ignaciojimenezr <67474336+ignaciojimenezr@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:13:03 -0700 Subject: [PATCH 6/6] prefer owned workspace fallback --- .../__tests__/use-workspace-state.test.tsx | 140 +++++++++++++++++- .../client/src/hooks/use-workspace-state.ts | 42 ++++-- .../client/src/hooks/useWorkspaces.ts | 1 + 3 files changed, 171 insertions(+), 12 deletions(-) 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 271d548e9..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 @@ -382,6 +382,140 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); + 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(), + }); + 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", { @@ -1426,7 +1560,7 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("stops workspace bootstrap loading if the bootstrap import mutation fails", async () => { + it("stops workspace bootstrap loading if the guest server carry-forward mutation fails", async () => { workspaceQueryState.allWorkspaces = []; workspaceQueryState.workspaces = []; bootstrapGuestServerImportMock.mockRejectedValueOnce( @@ -1646,7 +1780,7 @@ describe("useWorkspaceState automatic workspace creation", () => { }); }); - it("ignores a stale bootstrap import result that resolves after sign-out", async () => { + it("ignores a stale guest server carry-forward result that resolves after sign-out", async () => { workspaceQueryState.allWorkspaces = [ { _id: "remote-1", @@ -1725,7 +1859,7 @@ describe("useWorkspaceState automatic workspace creation", () => { ); }); - it("ignores a stale bootstrap result after sign-out and allows a new sign-in pass", async () => { + 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", diff --git a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts index 579ff0b17..0c779aa9d 100644 --- a/mcpjam-inspector/client/src/hooks/use-workspace-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-workspace-state.ts @@ -10,6 +10,7 @@ import { toast } from "sonner"; import type { AppAction, AppState, Workspace } from "@/state/app-types"; import { type BootstrapGuestSourceWorkspace, + type RemoteWorkspace, useWorkspaceMutations, useWorkspaceQueries, useWorkspaceServers, @@ -30,7 +31,7 @@ import { useClientConfigStore } from "@/stores/client-config-store"; import { useOrganizationBillingStatus } from "./useOrganizationBilling"; const CLIENT_CONFIG_SYNC_ECHO_TIMEOUT_MS = 10000; -const WORKSPACE_BOOTSTRAP_TIMEOUT_MS = 5000; +const GUEST_SERVER_CARRY_FORWARD_TIMEOUT_MS = 5000; function stringifyWorkspaceClientConfig( clientConfig: WorkspaceClientConfig | undefined, @@ -68,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; @@ -133,7 +153,7 @@ export function useWorkspaceState({ [carryForwardLocalWorkspaces], ); const hasCarryForwardCandidates = bootstrapGuestSourceWorkspaces.length > 0; - // Hold off Convex queries until the bootstrap import finishes, preventing + // Hold off Convex queries until guest server carry-forward finishes, preventing // the UI from briefly showing an empty or partial workspace. const shouldPauseRemoteBootstrapQueries = isAuthenticated && @@ -412,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; }, [ @@ -453,7 +475,9 @@ export function useWorkspaceState({ if (savedActiveId && convexWorkspaces[savedActiveId]) { setConvexActiveWorkspaceId(savedActiveId); } else { - setConvexActiveWorkspaceId(remoteWorkspaces[0]._id); + setConvexActiveWorkspaceId( + selectFallbackRemoteWorkspace(remoteWorkspaces)._id, + ); } } } @@ -505,14 +529,14 @@ export function useWorkspaceState({ } workspaceBootstrapTimeoutRef.current = null; workspaceBootstrapTimeoutGenerationRef.current = null; - logger.warn("Workspace bootstrap import timed out", { - timeoutMs: WORKSPACE_BOOTSTRAP_TIMEOUT_MS, + 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); - }, WORKSPACE_BOOTSTRAP_TIMEOUT_MS); + }, GUEST_SERVER_CARRY_FORWARD_TIMEOUT_MS); } }, [ canContinueBootstrapGeneration, @@ -715,7 +739,7 @@ export function useWorkspaceState({ } if (result.timedOut) { - logger.warn("Workspace bootstrap import timed out in Convex", { + logger.warn("Guest server carry-forward timed out in Convex", { targetWorkspaceId: result.targetWorkspaceId, }); toast.warning( diff --git a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts index 24bc43624..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; }