Skip to content

Commit b5196e2

Browse files
Preserve OAuth setup when retrying OAuth reconnect. (#1922)
1 parent f754952 commit b5196e2

13 files changed

Lines changed: 976 additions & 66 deletions

mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ export function ServerConnectionCard({
240240
}
241241
};
242242

243+
const getSwitchReconnectOptions = () => {
244+
if (server.useOAuth === true && !server.oauthTokens) {
245+
return HOSTED_MODE
246+
? { allowInteractiveOAuthFlow: true }
247+
: { forceOAuthFlow: true };
248+
}
249+
250+
return { allowInteractiveOAuthFlow: false };
251+
};
252+
243253
const handleExport = async () => {
244254
setIsExporting(true);
245255
try {
@@ -505,9 +515,7 @@ export function ServerConnectionCard({
505515
if (!checked) {
506516
onDisconnect(server.name);
507517
} else {
508-
void handleReconnect({
509-
allowInteractiveOAuthFlow: false,
510-
});
518+
void handleReconnect(getSwitchReconnectOptions());
511519
}
512520
}}
513521
className="cursor-pointer scale-75"

mcpjam-inspector/client/src/components/connection/ServerDetailModal.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
isOpenAIApp,
2727
isOpenAIAppAndMCPApp,
2828
} from "@/lib/mcp-ui/mcp-apps-utils";
29+
import { HOSTED_MODE } from "@/lib/config";
2930
import { getConnectionStatusMeta } from "./server-card-utils";
3031
import { useServerForm } from "./hooks/use-server-form";
3132
import { ServerInfoContent } from "./ServerInfoContent";
@@ -212,6 +213,16 @@ export function ServerDetailModal({
212213
}
213214
};
214215

216+
const getSwitchReconnectOptions = () => {
217+
if (server.useOAuth === true && !server.oauthTokens) {
218+
return HOSTED_MODE
219+
? { allowInteractiveOAuthFlow: true }
220+
: { forceOAuthFlow: true };
221+
}
222+
223+
return { allowInteractiveOAuthFlow: false };
224+
};
225+
215226
const handleDisconnect = () => {
216227
posthog.capture("server_detail_modal_disconnect_clicked", {
217228
platform: detectPlatform(),
@@ -313,9 +324,7 @@ export function ServerDetailModal({
313324
if (!checked) {
314325
handleDisconnect();
315326
} else {
316-
void handleConnect({
317-
allowInteractiveOAuthFlow: false,
318-
});
327+
void handleConnect(getSwitchReconnectOptions());
319328
}
320329
}}
321330
className="cursor-pointer scale-75"

mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.hosted.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,32 @@ describe("ServerConnectionCard hosted reconnect guard", () => {
102102
expect(onReconnect).not.toHaveBeenCalled();
103103
});
104104

105+
it("allows interactive OAuth reconnect for OAuth servers without tokens", () => {
106+
const onReconnect = vi.fn().mockResolvedValue(undefined);
107+
const server = createServer({
108+
name: "oauth-server",
109+
useOAuth: true,
110+
config: {
111+
transportType: "streamableHttp",
112+
url: "https://example.com/mcp",
113+
},
114+
});
115+
116+
render(
117+
<ServerConnectionCard
118+
server={server}
119+
onDisconnect={vi.fn()}
120+
onReconnect={onReconnect}
121+
/>,
122+
);
123+
124+
fireEvent.click(screen.getByRole("switch"));
125+
126+
expect(onReconnect).toHaveBeenCalledWith("oauth-server", {
127+
allowInteractiveOAuthFlow: true,
128+
});
129+
});
130+
105131
it("hides the share CTA even for share-eligible hosted servers", () => {
106132
const server = createServer({
107133
name: "shareable-server",

mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,28 @@ describe("ServerConnectionCard", () => {
258258
});
259259
});
260260

261+
it("forces a fresh OAuth flow when toggling on an OAuth server without tokens", () => {
262+
const server = createServer({
263+
connectionStatus: "disconnected",
264+
useOAuth: true,
265+
config: { url: "https://example.com/mcp" } as any,
266+
});
267+
const onReconnect = vi.fn().mockResolvedValue(undefined);
268+
render(
269+
<ServerConnectionCard
270+
server={server}
271+
{...defaultProps}
272+
onReconnect={onReconnect}
273+
/>,
274+
);
275+
276+
fireEvent.click(screen.getByRole("switch"));
277+
278+
expect(onReconnect).toHaveBeenCalledWith("test-server", {
279+
forceOAuthFlow: true,
280+
});
281+
});
282+
261283
it("catches rejected reconnect promises and clears reconnect loading state", async () => {
262284
const server = createServer({ connectionStatus: "disconnected" });
263285
const onReconnect = vi.fn().mockImplementation(
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it, vi, beforeEach } from "vitest";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import type { ServerWithName } from "@/hooks/use-app-state";
4+
5+
vi.mock("@/lib/config", () => ({
6+
HOSTED_MODE: true,
7+
}));
8+
9+
vi.mock("posthog-js/react", () => ({
10+
usePostHog: () => ({
11+
capture: vi.fn(),
12+
}),
13+
}));
14+
15+
vi.mock("sonner", () => ({
16+
toast: {
17+
error: vi.fn(),
18+
},
19+
}));
20+
21+
vi.mock("@/lib/apis/mcp-tools-api", () => ({
22+
listTools: vi.fn().mockResolvedValue({ tools: [], toolsMetadata: {} }),
23+
}));
24+
25+
vi.mock("@/lib/mcp-ui/mcp-apps-utils", () => ({
26+
isMCPApp: () => false,
27+
isOpenAIApp: () => false,
28+
isOpenAIAppAndMCPApp: () => false,
29+
}));
30+
31+
import { ServerDetailModal } from "../ServerDetailModal";
32+
33+
function createServer(
34+
overrides: Partial<ServerWithName> = {},
35+
): ServerWithName {
36+
return {
37+
name: "test-server",
38+
lastConnectionTime: new Date(),
39+
connectionStatus: "disconnected",
40+
enabled: true,
41+
retryCount: 0,
42+
useOAuth: false,
43+
config: {
44+
url: "https://example.com/mcp",
45+
},
46+
...overrides,
47+
};
48+
}
49+
50+
describe("ServerDetailModal hosted reconnect", () => {
51+
const defaultProps = {
52+
isOpen: true,
53+
onClose: vi.fn(),
54+
server: createServer(),
55+
defaultTab: "configuration" as const,
56+
onSubmit: vi.fn().mockResolvedValue({
57+
ok: true,
58+
serverName: "test-server",
59+
}),
60+
onDisconnect: vi.fn(),
61+
onReconnect: vi.fn().mockResolvedValue(undefined),
62+
existingServerNames: ["test-server"],
63+
};
64+
65+
beforeEach(() => {
66+
vi.clearAllMocks();
67+
localStorage.clear();
68+
});
69+
70+
it("allows interactive OAuth reconnect for OAuth servers without tokens", () => {
71+
const onReconnect = vi.fn().mockResolvedValue(undefined);
72+
73+
render(
74+
<ServerDetailModal
75+
{...defaultProps}
76+
server={createServer({ useOAuth: true })}
77+
onReconnect={onReconnect}
78+
/>,
79+
);
80+
81+
fireEvent.click(screen.getByRole("switch"));
82+
83+
expect(onReconnect).toHaveBeenCalledWith("test-server", {
84+
allowInteractiveOAuthFlow: true,
85+
});
86+
});
87+
});

mcpjam-inspector/client/src/components/connection/__tests__/ServerDetailModal.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,26 @@ describe("ServerDetailModal", () => {
215215
});
216216
});
217217

218+
it("forces a fresh OAuth flow when toggling on an OAuth server without tokens", () => {
219+
const onReconnect = vi.fn().mockResolvedValue(undefined);
220+
render(
221+
<ServerDetailModal
222+
{...defaultProps}
223+
server={createServer({
224+
connectionStatus: "disconnected",
225+
useOAuth: true,
226+
})}
227+
onReconnect={onReconnect}
228+
/>,
229+
);
230+
231+
fireEvent.click(screen.getByRole("switch"));
232+
233+
expect(onReconnect).toHaveBeenCalledWith("test-server", {
234+
forceOAuthFlow: true,
235+
});
236+
});
237+
218238
it("does not show a conformance launch button in overview", () => {
219239
render(<ServerDetailModal {...defaultProps} defaultTab="overview" />);
220240

0 commit comments

Comments
 (0)