diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx
index 6252c1161..8700a87ed 100644
--- a/client/src/components/AuthDebugger.tsx
+++ b/client/src/components/AuthDebugger.tsx
@@ -1,12 +1,24 @@
-import { useCallback, useMemo, useEffect } from "react";
+import { useCallback, useMemo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
-import { DebugInspectorOAuthClientProvider } from "../lib/auth";
+import {
+ DebugInspectorOAuthClientProvider,
+ clearClientInformationFromSessionStorage,
+} from "../lib/auth";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
import { SESSION_KEYS } from "../lib/constants";
import { validateRedirectUrl } from "@/utils/urlValidation";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Checkbox } from "@/components/ui/checkbox";
export interface AuthDebuggerProps {
serverUrl: string;
@@ -61,6 +73,9 @@ const AuthDebugger = ({
authState,
updateAuthState,
}: AuthDebuggerProps) => {
+ const [clearDialogOpen, setClearDialogOpen] = useState(false);
+ const [clearDcrClient, setClearDcrClient] = useState(false);
+
// Check for existing tokens on mount
useEffect(() => {
if (serverUrl && !authState.oauthTokens) {
@@ -94,8 +109,15 @@ const AuthDebugger = ({
return;
}
+ // Clear cached DCR client info so Step 2 performs a fresh registration
+ clearClientInformationFromSessionStorage({
+ serverUrl,
+ isPreregistered: false,
+ });
+
updateAuthState({
oauthStep: "metadata_discovery",
+ oauthClientInfo: null,
authorizationUrl: null,
statusMessage: null,
latestError: null,
@@ -216,26 +238,42 @@ const AuthDebugger = ({
}
}, [serverUrl, updateAuthState, authState]);
- const handleClearOAuth = useCallback(() => {
- if (serverUrl) {
- const serverAuthProvider = new DebugInspectorOAuthClientProvider(
- serverUrl,
- );
- serverAuthProvider.clear();
- updateAuthState({
- ...EMPTY_DEBUGGER_STATE,
- statusMessage: {
- type: "success",
- message: "OAuth tokens cleared successfully",
- },
- });
+ const handleClearOAuth = useCallback(
+ (alsoClearDcrClient: boolean) => {
+ if (serverUrl) {
+ const serverAuthProvider = new DebugInspectorOAuthClientProvider(
+ serverUrl,
+ );
+ serverAuthProvider.clear();
- // Clear success message after 3 seconds
- setTimeout(() => {
- updateAuthState({ statusMessage: null });
- }, 3000);
- }
- }, [serverUrl, updateAuthState]);
+ if (alsoClearDcrClient) {
+ clearClientInformationFromSessionStorage({
+ serverUrl,
+ isPreregistered: false,
+ });
+ }
+
+ updateAuthState({
+ ...EMPTY_DEBUGGER_STATE,
+ oauthClientInfo: alsoClearDcrClient
+ ? null
+ : EMPTY_DEBUGGER_STATE.oauthClientInfo,
+ statusMessage: {
+ type: "success",
+ message: alsoClearDcrClient
+ ? "OAuth tokens and client registration cleared successfully"
+ : "OAuth tokens cleared successfully",
+ },
+ });
+
+ // Clear success message after 3 seconds
+ setTimeout(() => {
+ updateAuthState({ statusMessage: null });
+ }, 3000);
+ }
+ },
+ [serverUrl, updateAuthState],
+ );
return (
@@ -295,7 +333,13 @@ const AuthDebugger = ({
: "Quick OAuth Flow"}
-
@@ -316,6 +360,50 @@ const AuthDebugger = ({
+
+
);
};
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx
index fec876778..f0da82e31 100644
--- a/client/src/components/__tests__/AuthDebugger.test.tsx
+++ b/client/src/components/__tests__/AuthDebugger.test.tsx
@@ -64,7 +64,6 @@ jest.mock("@/lib/auth", () => ({
clear: jest.fn().mockImplementation(() => {
// Mock the real clear() behavior which removes items from sessionStorage
sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens");
- sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info");
sessionStorage.removeItem(
"[https://example.com/mcp] mcp_server_metadata",
);
@@ -103,10 +102,14 @@ jest.mock("@/lib/auth", () => ({
saveServerMetadata: jest.fn(),
getServerMetadata: jest.fn(),
})),
- discoverScopes: jest.fn().mockResolvedValue("read write" as never),
+ clearClientInformationFromSessionStorage: jest.fn(),
+ discoverScopes: jest.fn().mockResolvedValue("read write"),
}));
-import { discoverScopes } from "../../lib/auth";
+import {
+ discoverScopes,
+ clearClientInformationFromSessionStorage,
+} from "../../lib/auth";
// Type the mocked functions properly
const mockDiscoverAuthorizationServerMetadata =
@@ -130,6 +133,10 @@ const mockDiscoverOAuthProtectedResourceMetadata =
const mockDiscoverScopes = discoverScopes as jest.MockedFunction<
typeof discoverScopes
>;
+const mockClearClientInformation =
+ clearClientInformationFromSessionStorage as jest.MockedFunction<
+ typeof clearClientInformationFromSessionStorage
+ >;
const sessionStorageMock = {
getItem: jest.fn(),
@@ -234,6 +241,31 @@ describe("AuthDebugger", () => {
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
});
+ it("should clear cached DCR client info when starting Guided OAuth Flow", async () => {
+ const updateAuthState = jest.fn();
+ await act(async () => {
+ renderAuthDebugger({ updateAuthState });
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Guided OAuth Flow"));
+ });
+
+ // Should clear DCR client info from sessionStorage
+ expect(mockClearClientInformation).toHaveBeenCalledWith({
+ serverUrl: "https://example.com/mcp",
+ isPreregistered: false,
+ });
+
+ // Should also clear oauthClientInfo in React state
+ expect(updateAuthState).toHaveBeenCalledWith(
+ expect.objectContaining({
+ oauthClientInfo: null,
+ oauthStep: "metadata_discovery",
+ }),
+ );
+ });
+
it("should show error when OAuth flow is started without sseUrl", async () => {
const updateAuthState = jest.fn();
await act(async () => {
@@ -355,9 +387,65 @@ describe("AuthDebugger", () => {
});
describe("OAuth State Management", () => {
- it("should clear OAuth state when Clear button is clicked", async () => {
+ it("should open confirmation dialog when Clear OAuth State is clicked", async () => {
+ await act(async () => {
+ renderAuthDebugger({
+ authState: {
+ ...defaultAuthState,
+ oauthTokens: mockOAuthTokens,
+ },
+ });
+ });
+
+ await act(async () => {
+ fireEvent.click(screen.getByText("Clear OAuth State"));
+ });
+
+ // Dialog should be visible
+ expect(
+ screen.getByText(
+ "This will clear OAuth tokens, code verifier, and server metadata for this session.",
+ ),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText("Also clear registered client information"),
+ ).toBeInTheDocument();
+ });
+
+ it("should close dialog without clearing when Cancel is clicked", async () => {
+ const updateAuthState = jest.fn();
+ await act(async () => {
+ renderAuthDebugger({
+ authState: {
+ ...defaultAuthState,
+ oauthTokens: mockOAuthTokens,
+ },
+ updateAuthState,
+ });
+ });
+
+ // Open dialog
+ await act(async () => {
+ fireEvent.click(screen.getByText("Clear OAuth State"));
+ });
+
+ // Click Cancel
+ await act(async () => {
+ fireEvent.click(screen.getByText("Cancel"));
+ });
+
+ // updateAuthState should not have been called with a clear action
+ expect(updateAuthState).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ statusMessage: expect.objectContaining({
+ message: expect.stringContaining("cleared"),
+ }),
+ }),
+ );
+ });
+
+ it("should clear OAuth state without DCR client when Clear is clicked without checkbox", async () => {
const updateAuthState = jest.fn();
- // Mock the session storage to return tokens for the specific key
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return JSON.stringify(mockOAuthTokens);
@@ -375,10 +463,16 @@ describe("AuthDebugger", () => {
});
});
+ // Open dialog
await act(async () => {
fireEvent.click(screen.getByText("Clear OAuth State"));
});
+ // Click Clear without checking the checkbox
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
+ });
+
expect(updateAuthState).toHaveBeenCalledWith({
authServerUrl: null,
authorizationUrl: null,
@@ -401,6 +495,60 @@ describe("AuthDebugger", () => {
// Verify session storage was cleared
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
+
+ // Should NOT clear DCR client info
+ expect(mockClearClientInformation).not.toHaveBeenCalled();
+ });
+
+ it("should clear OAuth state and DCR client when Clear is clicked with checkbox checked", async () => {
+ const updateAuthState = jest.fn();
+ sessionStorageMock.getItem.mockImplementation((key) => {
+ if (key === "[https://example.com] mcp_tokens") {
+ return JSON.stringify(mockOAuthTokens);
+ }
+ return null;
+ });
+
+ await act(async () => {
+ renderAuthDebugger({
+ authState: {
+ ...defaultAuthState,
+ oauthTokens: mockOAuthTokens,
+ },
+ updateAuthState,
+ });
+ });
+
+ // Open dialog
+ await act(async () => {
+ fireEvent.click(screen.getByText("Clear OAuth State"));
+ });
+
+ // Check the checkbox
+ await act(async () => {
+ fireEvent.click(screen.getByRole("checkbox"));
+ });
+
+ // Click Clear
+ await act(async () => {
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
+ });
+
+ expect(updateAuthState).toHaveBeenCalledWith(
+ expect.objectContaining({
+ statusMessage: {
+ type: "success",
+ message:
+ "OAuth tokens and client registration cleared successfully",
+ },
+ }),
+ );
+
+ // Should also clear DCR client info
+ expect(mockClearClientInformation).toHaveBeenCalledWith({
+ serverUrl: "https://example.com/mcp",
+ isPreregistered: false,
+ });
});
});
@@ -710,6 +858,7 @@ describe("AuthDebugger", () => {
// Verify that the flow started with metadata discovery
expect(updateAuthState).toHaveBeenCalledWith({
oauthStep: "metadata_discovery",
+ oauthClientInfo: null,
authorizationUrl: null,
statusMessage: null,
latestError: null,
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts
index 879936104..699b33862 100644
--- a/client/src/lib/auth.ts
+++ b/client/src/lib/auth.ts
@@ -245,10 +245,6 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
clear() {
- clearClientInformationFromSessionStorage({
- serverUrl: this.serverUrl,
- isPreregistered: false,
- });
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);