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