Merge branch 'main' into main

This commit is contained in:
Cliff Hall
2025-05-14 16:09:23 -04:00
committed by GitHub
11 changed files with 1391 additions and 13 deletions

View File

@@ -17,6 +17,9 @@ import {
Tool, Tool,
LoggingLevel, LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState } from "./lib/auth-types";
import React, { import React, {
Suspense, Suspense,
useCallback, useCallback,
@@ -28,18 +31,21 @@ import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes"; import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { import {
Bell, Bell,
Files, Files,
FolderTree, FolderTree,
Hammer, Hammer,
Hash, Hash,
Key,
MessageSquare, MessageSquare,
} from "lucide-react"; } from "lucide-react";
import { z } from "zod"; import { z } from "zod";
import "./App.css"; import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab"; import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History"; import HistoryAndNotifications from "./components/History";
import PingTab from "./components/PingTab"; import PingTab from "./components/PingTab";
@@ -111,6 +117,27 @@ const App = () => {
} }
> >
>([]); >([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] = useState<AuthDebuggerState>({
isInitiatingAuth: false,
oauthTokens: null,
loading: true,
oauthStep: "metadata_discovery",
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
});
// Helper function to update specific auth state properties
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
const nextRequestId = useRef(0); const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]); const rootsRef = useRef<Root[]>([]);
@@ -208,11 +235,64 @@ const App = () => {
(serverUrl: string) => { (serverUrl: string) => {
setSseUrl(serverUrl); setSseUrl(serverUrl);
setTransportType("sse"); setTransportType("sse");
setIsAuthDebuggerVisible(false);
void connectMcpServer(); void connectMcpServer();
}, },
[connectMcpServer], [connectMcpServer],
); );
// Update OAuth debug state during debug callback
const onOAuthDebugConnect = useCallback(
({
authorizationCode,
errorMsg,
}: {
authorizationCode?: string;
errorMsg?: string;
}) => {
setIsAuthDebuggerVisible(true);
if (authorizationCode) {
updateAuthState({
authorizationCode,
oauthStep: "token_request",
});
}
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
}
},
[],
);
// Load OAuth tokens when sseUrl changes
useEffect(() => {
const loadOAuthTokens = async () => {
try {
if (sseUrl) {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
const tokens = sessionStorage.getItem(key);
if (tokens) {
const parsedTokens = await OAuthTokensSchema.parseAsync(
JSON.parse(tokens),
);
updateAuthState({
oauthTokens: parsedTokens,
oauthStep: "complete",
});
}
}
} catch (error) {
console.error("Error loading OAuth tokens:", error);
} finally {
updateAuthState({ loading: false });
}
};
loadOAuthTokens();
}, [sseUrl]);
useEffect(() => { useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`) fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json()) .then((response) => response.json())
@@ -446,6 +526,19 @@ const App = () => {
setStdErrNotifications([]); setStdErrNotifications([]);
}; };
// Helper component for rendering the AuthDebugger
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
serverUrl={sseUrl}
onBack={() => setIsAuthDebuggerVisible(false)}
authState={authState}
updateAuthState={updateAuthState}
/>
</TabsContent>
);
// Helper function to render OAuth callback components
if (window.location.pathname === "/oauth/callback") { if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy( const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"), () => import("./components/OAuthCallback"),
@@ -457,6 +550,17 @@ const App = () => {
); );
} }
if (window.location.pathname === "/oauth/callback/debug") {
const OAuthDebugCallback = React.lazy(
() => import("./components/OAuthDebugCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
</Suspense>
);
}
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
@@ -544,6 +648,10 @@ const App = () => {
<FolderTree className="w-4 h-4 mr-2" /> <FolderTree className="w-4 h-4 mr-2" />
Roots Roots
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
</TabsList> </TabsList>
<div className="w-full"> <div className="w-full">
@@ -689,15 +797,36 @@ const App = () => {
setRoots={setRoots} setRoots={setRoots}
onRootsChange={handleRootsChange} onRootsChange={handleRootsChange}
/> />
<AuthDebuggerWrapper />
</> </>
)} )}
</div> </div>
</Tabs> </Tabs>
) : isAuthDebuggerVisible ? (
<Tabs
defaultValue={"auth"}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<AuthDebuggerWrapper />
</Tabs>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500"> <p className="text-lg text-gray-500">
Connect to an MCP server to start inspecting Connect to an MCP server to start inspecting
</p> </p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Need to configure authentication?
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAuthDebuggerVisible(true)}
>
Open Auth Settings
</Button>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,260 @@
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
export interface AuthDebuggerProps {
serverUrl: string;
onBack: () => void;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
}
interface StatusMessageProps {
message: { type: "error" | "success" | "info"; message: string };
}
const StatusMessage = ({ message }: StatusMessageProps) => {
let bgColor: string;
let textColor: string;
let borderColor: string;
switch (message.type) {
case "error":
bgColor = "bg-red-50";
textColor = "text-red-700";
borderColor = "border-red-200";
break;
case "success":
bgColor = "bg-green-50";
textColor = "text-green-700";
borderColor = "border-green-200";
break;
case "info":
default:
bgColor = "bg-blue-50";
textColor = "text-blue-700";
borderColor = "border-blue-200";
break;
}
return (
<div
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<p className="text-sm">{message.message}</p>
</div>
</div>
);
};
const AuthDebugger = ({
serverUrl: serverUrl,
onBack,
authState,
updateAuthState,
}: AuthDebuggerProps) => {
const startOAuthFlow = useCallback(() => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({
oauthStep: "metadata_discovery",
authorizationUrl: null,
statusMessage: null,
latestError: null,
});
}, [serverUrl, updateAuthState]);
const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState),
[serverUrl, updateAuthState],
);
const proceedToNextStep = useCallback(async () => {
if (!serverUrl) return;
try {
updateAuthState({
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
});
await stateMachine.executeStep(authState);
} catch (error) {
console.error("OAuth flow error:", error);
updateAuthState({
latestError: error instanceof Error ? error : new Error(String(error)),
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, authState, updateAuthState, stateMachine]);
const handleQuickOAuth = useCallback(async () => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
try {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
await auth(serverAuthProvider, { serverUrl: serverUrl });
updateAuthState({
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
},
});
} catch (error) {
console.error("OAuth initialization error:", error);
updateAuthState({
statusMessage: {
type: "error",
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, updateAuthState]);
const handleClearOAuth = useCallback(() => {
if (serverUrl) {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
serverAuthProvider.clear();
updateAuthState({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
authorizationCode: "",
validationError: null,
oauthMetadata: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Clear success message after 3 seconds
setTimeout(() => {
updateAuthState({ statusMessage: null });
}, 3000);
}
}, [serverUrl, updateAuthState]);
return (
<div className="w-full p-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Authentication Settings</h2>
<Button variant="outline" onClick={onBack}>
Back to Connect
</Button>
</div>
<div className="w-full space-y-6">
<div className="flex flex-col gap-6">
<div className="grid w-full gap-2">
<p className="text-muted-foreground mb-4">
Configure authentication settings for your MCP server connection.
</p>
<div className="rounded-md border p-6 space-y-6">
<h3 className="text-lg font-medium">OAuth Authentication</h3>
<p className="text-sm text-muted-foreground mb-2">
Use OAuth to securely authenticate with the MCP server.
</p>
{authState.statusMessage && (
<StatusMessage message={authState.statusMessage} />
)}
{authState.loading ? (
<p>Loading authentication status...</p>
) : (
<div className="space-y-4">
{authState.oauthTokens && (
<div className="space-y-2">
<p className="text-sm font-medium">Access Token:</p>
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
{authState.oauthTokens.access_token.substring(0, 25)}...
</div>
</div>
)}
<div className="flex gap-4">
<Button
variant="outline"
onClick={startOAuthFlow}
disabled={authState.isInitiatingAuth}
>
{authState.oauthTokens
? "Guided Token Refresh"
: "Guided OAuth Flow"}
</Button>
<Button
onClick={handleQuickOAuth}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Initiating..."
: authState.oauthTokens
? "Quick Refresh"
: "Quick OAuth Flow"}
</Button>
<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose "Guided" for step-by-step instructions or "Quick" for
the standard automatic flow.
</p>
</div>
)}
</div>
<OAuthFlowProgress
serverUrl={serverUrl}
authState={authState}
updateAuthState={updateAuthState}
proceedToNextStep={proceedToNextStep}
/>
</div>
</div>
</div>
</div>
);
};
export default AuthDebugger;

View File

@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { SESSION_KEYS } from "../lib/constants";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
interface OAuthCallbackProps {
onConnect: ({
authorizationCode,
errorMsg,
}: {
authorizationCode?: string;
errorMsg?: string;
}) => void;
}
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
useEffect(() => {
let isProcessed = false;
const handleCallback = async () => {
// Skip if we've already processed this callback
if (isProcessed) {
return;
}
isProcessed = true;
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
const errorMsg = generateOAuthErrorDescription(params);
onConnect({ errorMsg });
return;
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
// ServerURL isn't set, this can happen if we've opened the
// authentication request in a new tab, so we don't have the same
// session storage
if (!serverUrl) {
// If there's no server URL, we're likely in a new tab
// Just display the code for manual copying
return;
}
if (!params.code) {
onConnect({ errorMsg: "Missing authorization code" });
return;
}
// Instead of storing in sessionStorage, pass the code directly
// to the auth state manager through onConnect
onConnect({ authorizationCode: params.code });
};
handleCallback().finally(() => {
// Only redirect if we have the URL set, otherwise assume this was
// in a new tab
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
window.history.replaceState({}, document.title, "/");
}
});
return () => {
isProcessed = true;
};
}, [onConnect]);
const callbackParams = parseOAuthCallbackParams(window.location.search);
return (
<div className="flex items-center justify-center h-screen">
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
<p className="mb-2 text-sm">
Please copy this authorization code and return to the Auth Debugger:
</p>
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
{callbackParams.successful && "code" in callbackParams
? callbackParams.code
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
</code>
<p className="mt-4 text-xs text-muted-foreground">
Close this tab and paste the code in the OAuth flow to complete
authentication.
</p>
</div>
</div>
);
};
export default OAuthDebugCallback;

View File

@@ -0,0 +1,259 @@
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
interface OAuthStepProps {
label: string;
isComplete: boolean;
isCurrent: boolean;
error?: Error | null;
children?: React.ReactNode;
}
const OAuthStepDetails = ({
label,
isComplete,
isCurrent,
error,
children,
}: OAuthStepProps) => {
return (
<div>
<div
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
>
{isComplete ? (
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
) : (
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
)}
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
</div>
{/* Show children if current step or complete and children exist */}
{(isCurrent || isComplete) && children && (
<div className="ml-7 mt-1">{children}</div>
)}
{/* Display error if current step and an error exists */}
{isCurrent && error && (
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
<p className="text-sm font-medium text-red-700">Error:</p>
<p className="text-xs text-red-600 mt-1">{error.message}</p>
</div>
)}
</div>
);
};
interface OAuthFlowProgressProps {
serverUrl: string;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
proceedToNextStep: () => Promise<void>;
}
export const OAuthFlowProgress = ({
serverUrl,
authState,
updateAuthState,
proceedToNextStep,
}: OAuthFlowProgressProps) => {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const steps: Array<OAuthStep> = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
// Helper to get step props
const getStepProps = (stepName: OAuthStep) => ({
isComplete:
currentStepIdx > steps.indexOf(stepName) ||
currentStepIdx === steps.length - 1, // last step is "complete"
isCurrent: authState.oauthStep === stepName,
error: authState.oauthStep === stepName ? authState.latestError : null,
});
return (
<div className="rounded-md border p-6 space-y-4 mt-4">
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
<p className="text-sm text-muted-foreground">
Follow these steps to complete OAuth authentication with the server.
</p>
<div className="space-y-3">
<OAuthStepDetails
label="Metadata Discovery"
{...getStepProps("metadata_discovery")}
>
{provider.getServerMetadata() && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Retrieved OAuth Metadata from {serverUrl}
/.well-known/oauth-authorization-server
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(provider.getServerMetadata(), null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Client Registration"
{...getStepProps("client_registration")}
>
{authState.oauthClientInfo && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Registered Client Information
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthClientInfo, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Preparing Authorization"
{...getStepProps("authorization_redirect")}
>
{authState.authorizationUrl && (
<div className="mt-2 p-3 border rounded-md bg-muted">
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
<div className="flex items-center gap-2">
<p className="text-xs break-all">
{authState.authorizationUrl}
</p>
<a
href={authState.authorizationUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-blue-500 hover:text-blue-700"
aria-label="Open authorization URL in new tab"
title="Open authorization URL"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After
authorization, you'll be redirected back to continue the flow.
</p>
</div>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Request Authorization and acquire authorization code"
{...getStepProps("authorization_code")}
>
<div className="mt-3">
<label
htmlFor="authCode"
className="block text-sm font-medium mb-1"
>
Authorization Code
</label>
<div className="flex gap-2">
<input
id="authCode"
value={authState.authorizationCode}
onChange={(e) => {
updateAuthState({
authorizationCode: e.target.value,
validationError: null,
});
}}
placeholder="Enter the code from the authorization server"
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
authState.validationError ? "border-red-500" : "border-input"
}`}
/>
</div>
{authState.validationError && (
<p className="text-xs text-red-600 mt-1">
{authState.validationError}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Once you've completed authorization in the link, paste the code
here.
</p>
</div>
</OAuthStepDetails>
<OAuthStepDetails
label="Token Request"
{...getStepProps("token_request")}
>
{authState.oauthMetadata && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Token Request Details
</summary>
<div className="mt-2 p-2 bg-muted rounded-md">
<p className="font-medium">Token Endpoint:</p>
<code className="block mt-1 text-xs overflow-x-auto">
{authState.oauthMetadata.token_endpoint}
</code>
</div>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Authentication Complete"
{...getStepProps("complete")}
>
{authState.oauthTokens && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Access Tokens
</summary>
<p className="mt-1 text-sm">
Authentication successful! You can now use the authenticated
connection. These tokens will be used automatically for server
requests.
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthTokens, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
</div>
<div className="flex gap-3 mt-4">
{authState.oauthStep !== "complete" && (
<>
<Button
onClick={proceedToNextStep}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
</Button>
</>
)}
{authState.oauthStep === "authorization_redirect" &&
authState.authorizationUrl && (
<Button
variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
>
Open in New Tab
</Button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,382 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SESSION_KEYS } from "@/lib/constants";
const mockOAuthTokens = {
access_token: "test_access_token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "test_refresh_token",
scope: "test_scope",
};
const mockOAuthMetadata = {
issuer: "https://oauth.example.com",
authorization_endpoint: "https://oauth.example.com/authorize",
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
};
const mockOAuthClientInfo = {
client_id: "test_client_id",
client_secret: "test_client_secret",
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
};
// Mock MCP SDK functions - must be before imports
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
discoverOAuthMetadata: jest.fn(),
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
}));
// Import the functions to get their types
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
// Type the mocked functions properly
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
typeof discoverOAuthMetadata
>;
const mockRegisterClient = registerClient as jest.MockedFunction<
typeof registerClient
>;
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
typeof startAuthorization
>;
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
typeof exchangeAuthorization
>;
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = {
isInitiatingAuth: false,
oauthTokens: null,
loading: false,
oauthStep: "metadata_discovery" as const,
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
};
const defaultProps = {
serverUrl: "https://example.com",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
const authUrl = new URL("https://oauth.example.com/authorize");
if (options.scope) {
authUrl.searchParams.set("scope", options.scope);
}
return {
authorizationUrl: authUrl,
codeVerifier: "test_verifier",
};
});
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
});
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
const mergedProps = {
...defaultProps,
...props,
authState: { ...defaultAuthState, ...(props.authState || {}) },
};
return render(
<TooltipProvider>
<AuthDebugger {...mergedProps} />
</TooltipProvider>,
);
};
describe("Initial Rendering", () => {
it("should render the component with correct title", async () => {
await act(async () => {
renderAuthDebugger();
});
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
});
it("should call onBack when Back button is clicked", async () => {
const onBack = jest.fn();
await act(async () => {
renderAuthDebugger({ onBack });
});
fireEvent.click(screen.getByText("Back to Connect"));
expect(onBack).toHaveBeenCalled();
});
});
describe("OAuth Flow", () => {
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
await act(async () => {
renderAuthDebugger();
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
});
it("should show error when OAuth flow is started without sseUrl", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({ serverUrl: "", updateAuthState });
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(updateAuthState).toHaveBeenCalledWith({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
});
});
describe("Session Storage Integration", () => {
it("should load OAuth tokens from session storage", async () => {
// Mock the specific key for tokens with server URL
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,
},
});
});
await waitFor(() => {
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
});
});
it("should handle errors loading OAuth tokens from session storage", async () => {
// Mock console to avoid cluttering test output
const originalError = console.error;
console.error = jest.fn();
// Mock getItem to return invalid JSON for tokens
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return "invalid json";
}
return null;
});
await act(async () => {
renderAuthDebugger();
});
// Component should still render despite the error
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
// Restore console.error
console.error = originalError;
});
});
describe("OAuth State Management", () => {
it("should clear OAuth state when Clear button is clicked", 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);
}
return null;
});
await act(async () => {
renderAuthDebugger({
authState: {
...defaultAuthState,
oauthTokens: mockOAuthTokens,
},
updateAuthState,
});
});
await act(async () => {
fireEvent.click(screen.getByText("Clear OAuth State"));
});
expect(updateAuthState).toHaveBeenCalledWith({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
oauthMetadata: null,
authorizationCode: "",
validationError: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Verify session storage was cleared
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
});
});
describe("OAuth Flow Steps", () => {
it("should handle OAuth flow step progression", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false, // Changed to false so button is enabled
oauthStep: "metadata_discovery",
},
});
});
// Verify metadata discovery step
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
// Click Continue - this should trigger metadata discovery
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
);
});
// Setup helper for OAuth authorization tests
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
const updateAuthState = jest.fn();
// Mock the session storage to return metadata
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
return JSON.stringify(metadata);
}
if (
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
) {
return JSON.stringify(mockOAuthClientInfo);
}
return null;
});
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false,
oauthStep: "authorization_redirect",
oauthMetadata: metadata,
oauthClientInfo: mockOAuthClientInfo,
},
});
});
// Click Continue to trigger authorization
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
return updateAuthState;
};
it("should include scope in authorization URL when scopes_supported is present", async () => {
const metadataWithScopes = {
...mockOAuthMetadata,
scopes_supported: ["read", "write", "admin"],
};
const updateAuthState =
await setupAuthorizationUrlTest(metadataWithScopes);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
}),
);
});
});
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.not.stringContaining("scope="),
}),
);
});
});
});
});

View File

@@ -0,0 +1,38 @@
import {
OAuthMetadata,
OAuthClientInformationFull,
OAuthClientInformation,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
// OAuth flow steps
export type OAuthStep =
| "metadata_discovery"
| "client_registration"
| "authorization_redirect"
| "authorization_code"
| "token_request"
| "complete";
// Message types for inline feedback
export type MessageType = "success" | "error" | "info";
export interface StatusMessage {
type: MessageType;
message: string;
}
// Single state interface for OAuth state
export interface AuthDebuggerState {
isInitiatingAuth: boolean;
oauthTokens: OAuthTokens | null;
loading: boolean;
oauthStep: OAuthStep;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
authorizationUrl: string | null;
authorizationCode: string;
latestError: Error | null;
statusMessage: StatusMessage | null;
validationError: string | null;
}

View File

@@ -4,11 +4,13 @@ import {
OAuthClientInformation, OAuthClientInformation,
OAuthTokens, OAuthTokens,
OAuthTokensSchema, OAuthTokensSchema,
OAuthClientMetadata,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js"; } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants"; import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider { export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(private serverUrl: string) { constructor(public serverUrl: string) {
// Save the server URL to session storage // Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
} }
@@ -17,7 +19,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
return window.location.origin + "/oauth/callback"; return window.location.origin + "/oauth/callback";
} }
get clientMetadata() { get clientMetadata(): OAuthClientMetadata {
return { return {
redirect_uris: [this.redirectUrl], redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
@@ -101,3 +103,38 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
); );
} }
} }
// Overrides debug URL and allows saving server OAuth metadata to
// display in debug UI.
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
get redirectUrl(): string {
return `${window.location.origin}/oauth/callback/debug`;
}
saveServerMetadata(metadata: OAuthMetadata) {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(metadata));
}
getServerMetadata(): OAuthMetadata | null {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
const metadata = sessionStorage.getItem(key);
if (!metadata) {
return null;
}
return JSON.parse(metadata);
}
clear() {
super.clear();
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
);
}
}

View File

@@ -6,6 +6,7 @@ export const SESSION_KEYS = {
SERVER_URL: "mcp_server_url", SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens", TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information", CLIENT_INFORMATION: "mcp_client_information",
SERVER_METADATA: "mcp_server_metadata",
} as const; } as const;
// Generate server-specific session storage keys // Generate server-specific session storage keys

View File

@@ -0,0 +1,181 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth";
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
export interface StateMachineContext {
state: AuthDebuggerState;
serverUrl: string;
provider: DebugInspectorOAuthClientProvider;
updateState: (updates: Partial<AuthDebuggerState>) => void;
}
export interface StateTransition {
canTransition: (context: StateMachineContext) => Promise<boolean>;
execute: (context: StateMachineContext) => Promise<void>;
nextStep: OAuthStep;
}
// State machine transitions
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
metadata_discovery: {
canTransition: async () => true,
execute: async (context) => {
const metadata = await discoverOAuthMetadata(context.serverUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
context.provider.saveServerMetadata(parsedMetadata);
context.updateState({
oauthMetadata: parsedMetadata,
oauthStep: "client_registration",
});
},
nextStep: "client_registration",
},
client_registration: {
canTransition: async (context) => !!context.state.oauthMetadata,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientMetadata = context.provider.clientMetadata;
// Add all supported scopes to client registration
if (metadata.scopes_supported) {
clientMetadata.scope = metadata.scopes_supported.join(" ");
}
const fullInformation = await registerClient(context.serverUrl, {
metadata,
clientMetadata,
});
context.provider.saveClientInformation(fullInformation);
context.updateState({
oauthClientInfo: fullInformation,
oauthStep: "authorization_redirect",
});
},
nextStep: "authorization_redirect",
},
authorization_redirect: {
canTransition: async (context) =>
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientInformation = context.state.oauthClientInfo!;
let scope: string | undefined = undefined;
if (metadata.scopes_supported) {
scope = metadata.scopes_supported.join(" ");
}
const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl,
{
metadata,
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
},
);
context.provider.saveCodeVerifier(codeVerifier);
context.updateState({
authorizationUrl: authorizationUrl.toString(),
oauthStep: "authorization_code",
});
},
nextStep: "authorization_code",
},
authorization_code: {
canTransition: async () => true,
execute: async (context) => {
if (
!context.state.authorizationCode ||
context.state.authorizationCode.trim() === ""
) {
context.updateState({
validationError: "You need to provide an authorization code",
});
// Don't advance if no code
throw new Error("Authorization code required");
}
context.updateState({
validationError: null,
oauthStep: "token_request",
});
},
nextStep: "token_request",
},
token_request: {
canTransition: async (context) => {
return (
!!context.state.authorizationCode &&
!!context.provider.getServerMetadata() &&
!!(await context.provider.clientInformation())
);
},
execute: async (context) => {
const codeVerifier = context.provider.codeVerifier();
const metadata = context.provider.getServerMetadata()!;
const clientInformation = (await context.provider.clientInformation())!;
const tokens = await exchangeAuthorization(context.serverUrl, {
metadata,
clientInformation,
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
});
context.provider.saveTokens(tokens);
context.updateState({
oauthTokens: tokens,
oauthStep: "complete",
});
},
nextStep: "complete",
},
complete: {
canTransition: async () => false,
execute: async () => {
// No-op for complete state
},
nextStep: "complete",
},
};
export class OAuthStateMachine {
constructor(
private serverUrl: string,
private updateState: (updates: Partial<AuthDebuggerState>) => void,
) {}
async executeStep(state: AuthDebuggerState): Promise<void> {
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
const context: StateMachineContext = {
state,
serverUrl: this.serverUrl,
provider,
updateState: this.updateState,
};
const transition = oauthTransitions[state.oauthStep];
if (!(await transition.canTransition(context))) {
throw new Error(`Cannot transition from ${state.oauthStep}`);
}
await transition.execute(context);
}
}

15
package-lock.json generated
View File

@@ -17,7 +17,7 @@
"@modelcontextprotocol/inspector-cli": "^0.12.0", "@modelcontextprotocol/inspector-cli": "^0.12.0",
"@modelcontextprotocol/inspector-client": "^0.12.0", "@modelcontextprotocol/inspector-client": "^0.12.0",
"@modelcontextprotocol/inspector-server": "^0.12.0", "@modelcontextprotocol/inspector-server": "^0.12.0",
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"open": "^10.1.0", "open": "^10.1.0",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",
@@ -43,7 +43,7 @@
"version": "0.12.0", "version": "0.12.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2", "@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0", "commander": "^13.1.0",
"spawn-rx": "^5.1.2" "spawn-rx": "^5.1.2"
}, },
@@ -66,7 +66,7 @@
"version": "0.12.0", "version": "0.12.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2", "@modelcontextprotocol/sdk": "^1.11.0",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
@@ -2004,10 +2004,9 @@
"link": true "link": true
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.11.0", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz",
"integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^1.0.5",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -10937,7 +10936,7 @@
"version": "0.12.0", "version": "0.12.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.10.2", "@modelcontextprotocol/sdk": "^1.11.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"ws": "^8.18.0", "ws": "^8.18.0",

View File

@@ -43,7 +43,7 @@
"@modelcontextprotocol/inspector-cli": "^0.12.0", "@modelcontextprotocol/inspector-cli": "^0.12.0",
"@modelcontextprotocol/inspector-client": "^0.12.0", "@modelcontextprotocol/inspector-client": "^0.12.0",
"@modelcontextprotocol/inspector-server": "^0.12.0", "@modelcontextprotocol/inspector-server": "^0.12.0",
"@modelcontextprotocol/sdk": "^1.11.0", "@modelcontextprotocol/sdk": "^1.11.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"open": "^10.1.0", "open": "^10.1.0",
"shell-quote": "^1.8.2", "shell-quote": "^1.8.2",