Skip to content
Open
27 changes: 24 additions & 3 deletions mcpjam-inspector/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ export default function App() {
appState,
isLoading,
isLoadingRemoteWorkspaces,
isWorkspaceBootstrapLoading,
workspaceServers,
connectedOrConnectingServerConfigs,
selectedMCPConfig,
Expand Down Expand Up @@ -1296,6 +1297,11 @@ export default function App() {
!isOAuthCallback &&
billingEntitlementsUiEnabled !== false &&
pendingCheckoutIntent !== null;
const shouldShowWorkspaceBootstrapOverlay =
!shouldShowBillingHandoffOverlay &&
!isHostedChatRoute &&
!isOAuthCallback &&
isWorkspaceBootstrapLoading;

if (shouldHoldHostedDefaultRouteForAuth) {
return <LoadingScreen />;
Expand Down Expand Up @@ -1713,13 +1719,22 @@ export default function App() {
<AppStateProvider appState={effectiveAppState}>
<Toaster />
<div
aria-hidden={shouldShowBillingHandoffOverlay || undefined}
data-testid="app-shell-container"
aria-hidden={
shouldShowBillingHandoffOverlay ||
shouldShowWorkspaceBootstrapOverlay ||
undefined
}
className={
shouldShowBillingHandoffOverlay
shouldShowBillingHandoffOverlay || shouldShowWorkspaceBootstrapOverlay
? "pointer-events-none opacity-0"
: undefined
}
inert={shouldShowBillingHandoffOverlay || undefined}
inert={
shouldShowBillingHandoffOverlay ||
shouldShowWorkspaceBootstrapOverlay ||
undefined
}
>
<HostedShellGate
state={
Expand Down Expand Up @@ -1755,6 +1770,12 @@ export default function App() {
{shouldShowBillingHandoffOverlay ? (
<BillingHandoffLoading overlay />
) : null}
{shouldShowWorkspaceBootstrapOverlay ? (
<LoadingScreen
overlay
testId="workspace-bootstrap-loading-overlay"
/>
) : null}
</AppStateProvider>
</PreferencesStoreProvider>
);
Expand Down
46 changes: 45 additions & 1 deletion mcpjam-inspector/client/src/__tests__/App.hosted-oauth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const {
},
isLoading: false,
isLoadingRemoteWorkspaces: false,
isWorkspaceBootstrapLoading: false,
workspaceServers: {},
connectedOrConnectingServerConfigs: {},
selectedMCPConfig: null,
Expand Down Expand Up @@ -313,7 +314,12 @@ vi.mock("../components/CompletingSignInLoading", () => ({
default: () => <div />,
}));
vi.mock("../components/LoadingScreen", () => ({
default: () => <div data-testid="hosted-oauth-loading" />,
default: ({
testId,
}: {
overlay?: boolean;
testId?: string;
}) => <div data-testid={testId ?? "hosted-oauth-loading"} />,
}));
vi.mock("../components/Header", () => ({
Header: () => <div data-testid="app-header" />,
Expand Down Expand Up @@ -1302,6 +1308,44 @@ describe("App hosted OAuth callback handling", () => {
expect(screen.queryByText("Servers Tab")).not.toBeInTheDocument();
});

it("keeps the shell hidden behind the workspace bootstrap overlay until import finishes", async () => {
clearHostedOAuthPendingState();
clearSandboxSession();
window.history.replaceState({}, "", "/#servers");
mockHandleOAuthCallback.mockReset();

let bootstrapLoading = true;
mockUseAppState.mockImplementation(() => ({
...createAppStateMock(),
isWorkspaceBootstrapLoading: bootstrapLoading,
}));

const { rerender } = render(<App />);

await waitFor(() => {
expect(
screen.getByTestId("workspace-bootstrap-loading-overlay"),
).toBeInTheDocument();
expect(screen.getByTestId("app-shell-container")).toHaveAttribute(
"aria-hidden",
"true",
);
});

bootstrapLoading = false;
rerender(<App />);

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();
Expand Down
17 changes: 15 additions & 2 deletions mcpjam-inspector/client/src/components/LoadingScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
export default function LoadingScreen() {
export default function LoadingScreen({
overlay = false,
testId = "loading-screen",
}: {
overlay?: boolean;
testId?: string;
}) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div
className={
overlay
? "fixed inset-0 z-[90] flex items-center justify-center bg-background"
: "min-h-screen bg-background flex items-center justify-center"
}
data-testid={testId}
>
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-gray-200 border-t-primary mx-auto"></div>
</div>
Expand Down
1 change: 0 additions & 1 deletion mcpjam-inspector/client/src/components/ServersTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,6 @@ export function ServersTab({
},
[onUpdate, workspaceServers],
);

useEffect(() => {
if (!detailModalState.isOpen || detailModalLiveServer == null) {
return;
Expand Down
126 changes: 116 additions & 10 deletions mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const {
clearOAuthDataMock,
testConnectionMock,
mockConvexQuery,
createServerMutationMock,
updateServerMutationMock,
deleteServerMutationMock,
} = vi.hoisted(() => ({
toastError: vi.fn(),
toastSuccess: vi.fn(),
Expand All @@ -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", () => ({
Expand Down Expand Up @@ -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,
}),
}));

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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"));

Expand Down
Loading
Loading