Merge branch 'main' into fix-port-numbers

This commit is contained in:
Cliff Hall 2025-05-19 12:45:51 -04:00 • committed by GitHub
commit eb190b637d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 4913 additions and 442 deletions

View file

@ -17,6 +17,9 @@ import {
Tool,
LoggingLevel,
} 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, {
Suspense,
useCallback,
@ -28,18 +31,21 @@ import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
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 {
Bell,
Files,
FolderTree,
Hammer,
Hash,
Key,
MessageSquare,
} from "lucide-react";
import { z } from "zod";
import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
import PingTab from "./components/PingTab";
@ -49,9 +55,15 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils";
import {
getMCPProxyAddress,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
} from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@ -71,26 +83,13 @@ const App = () => {
prompts: null,
tools: null,
});
const [command, setCommand] = useState<string>(() => {
return localStorage.getItem("lastCommand") || "mcp-server-everything";
});
const [args, setArgs] = useState<string>(() => {
return localStorage.getItem("lastArgs") || "";
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(() => {
return (
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
});
>(getInitialTransportType);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
@ -99,27 +98,9 @@ const App = () => {
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// update description of keys to match the new description (in case of any updates to the default config description)
Object.entries(mergedConfig).forEach(([key, value]) => {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
});
return mergedConfig;
}
return DEFAULT_INSPECTOR_CONFIG;
});
const [config, setConfig] = useState<InspectorConfig>(() =>
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
);
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
@ -136,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 rootsRef = useRef<Root[]>([]);
@ -233,11 +235,64 @@ const App = () => {
(serverUrl: string) => {
setSseUrl(serverUrl);
setTransportType("sse");
setIsAuthDebuggerVisible(false);
void 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(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json())
@ -471,6 +526,19 @@ const App = () => {
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") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
@ -482,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 (
<div className="flex h-screen bg-background">
<Sidebar
@ -569,17 +648,34 @@ const App = () => {
<FolderTree className="w-4 h-4 mr-2" />
Roots
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
</p>
</div>
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
) : (
<>
<ResourcesTab
@ -701,15 +797,36 @@ const App = () => {
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
</>
)}
</div>
</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">
Connect to an MCP server to start inspecting
</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>

View file

@ -0,0 +1,272 @@
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import {
auth,
discoverOAuthMetadata,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/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,
);
// First discover OAuth metadata separately so we can save it
const metadata = await discoverOAuthMetadata(serverUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
serverAuthProvider.saveServerMetadata(parsedMetadata);
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

@ -3,7 +3,7 @@ import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { useToast } from "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
interface JsonViewProps {

View file

@ -2,7 +2,7 @@ import { useEffect, useRef } from "react";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts";
import { useToast } from "@/lib/hooks/useToast";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,

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

@ -7,7 +7,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { PendingRequest } from "./SamplingTab";
import DynamicJsonForm from "./DynamicJsonForm";
import { useToast } from "@/hooks/use-toast";
import { useToast } from "@/lib/hooks/useToast";
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
export type SamplingRequestProps = {

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useCallback } from "react";
import {
Play,
ChevronDown,
@ -12,6 +12,8 @@ import {
Settings,
HelpCircle,
RefreshCwOff,
Copy,
CheckCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -29,13 +31,14 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/useTheme";
import useTheme from "../lib/hooks/useTheme";
import { version } from "../../../package.json";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useToast } from "../lib/hooks/useToast";
interface SidebarProps {
connectionStatus: ConnectionStatus;
@ -95,6 +98,120 @@ const Sidebar = ({
const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
// Reusable error reporter for copy actions
const reportError = useCallback(
(error: unknown) => {
toast({
title: "Error",
description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
},
[toast],
);
// Shared utility function to generate server config
const generateServerConfig = useCallback(() => {
if (transportType === "stdio") {
return {
command,
args: args.trim() ? args.split(/\s+/) : [],
env: { ...env },
};
}
if (transportType === "sse") {
return {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
};
}
if (transportType === "streamable-http") {
return {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
};
}
return {};
}, [transportType, command, args, env, sseUrl]);
// Memoized config entry generator
const generateMCPServerEntry = useCallback(() => {
return JSON.stringify(generateServerConfig(), null, 4);
}, [generateServerConfig]);
// Memoized config file generator
const generateMCPServerFile = useCallback(() => {
return JSON.stringify(
{
mcpServers: {
"default-server": generateServerConfig(),
},
},
null,
4,
);
}, [generateServerConfig]);
// Memoized copy handlers
const handleCopyServerEntry = useCallback(() => {
try {
const configJson = generateMCPServerEntry();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerEntry(true);
toast({
title: "Config entry copied",
description:
transportType === "stdio"
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
: "SSE URL has been copied. Use this URL in Cursor directly.",
});
setTimeout(() => {
setCopiedServerEntry(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerEntry, transportType, toast, reportError]);
const handleCopyServerFile = useCallback(() => {
try {
const configJson = generateMCPServerFile();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerFile(true);
toast({
title: "Servers file copied",
description:
"Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'",
});
setTimeout(() => {
setCopiedServerFile(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerFile, toast, reportError]);
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
@ -223,6 +340,7 @@ const Sidebar = ({
</div>
</>
)}
{transportType === "stdio" && (
<div className="space-y-2">
<Button
@ -348,6 +466,46 @@ const Sidebar = ({
</div>
)}
{/* Always show both copy buttons for all transport types */}
<div className="grid grid-cols-2 gap-2 mt-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerEntry}
className="w-full"
>
{copiedServerEntry ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Server Entry
</Button>
</TooltipTrigger>
<TooltipContent>Copy Server Entry</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerFile}
className="w-full"
>
{copiedServerFile ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Servers File
</Button>
</TooltipTrigger>
<TooltipContent>Copy Servers File</TooltipContent>
</Tooltip>
</div>
{/* Configuration */}
<div className="space-y-2">
<Button

View file

@ -133,12 +133,12 @@ const ToolsTab = ({
}}
setSelectedItem={setSelectedTool}
renderItem={(tool) => (
<>
<div className="flex flex-col items-start">
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-right">
<span className="text-sm text-gray-500 text-left">
{tool.description}
</span>
</>
</div>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}

View file

@ -0,0 +1,441 @@
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,
auth,
} 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 mockAuth = auth as jest.MockedFunction<typeof auth>;
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",
},
});
});
it("should start quick OAuth flow and properly fetch and save metadata", async () => {
// Setup the auth mock
mockAuth.mockResolvedValue("AUTHORIZED");
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({ updateAuthState });
});
await act(async () => {
fireEvent.click(screen.getByText("Quick OAuth Flow"));
});
// Should first discover and save OAuth metadata
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
);
// Then should call auth with the server provider
expect(mockAuth).toHaveBeenCalled();
// Check that updateAuthState was called with the right info message
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
},
}),
);
});
it("should show error when quick OAuth flow fails to discover metadata", async () => {
mockDiscoverOAuthMetadata.mockRejectedValue(
new Error("Metadata discovery failed"),
);
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({ updateAuthState });
});
await act(async () => {
fireEvent.click(screen.getByText("Quick OAuth Flow"));
});
// Check that updateAuthState was called with an error message
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
statusMessage: {
type: "error",
message: expect.stringContaining("Failed to start OAuth flow"),
},
}),
);
});
});
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

@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
@ -7,11 +7,30 @@ import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook
jest.mock("../../lib/useTheme", () => ({
jest.mock("../../lib/hooks/useTheme", () => ({
__esModule: true,
default: () => ["light", jest.fn()],
}));
// Mock toast hook
const mockToast = jest.fn();
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
// Mock navigator clipboard
const mockClipboardWrite = jest.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockClipboardWrite,
},
});
// Setup fake timers
jest.useFakeTimers();
describe("Sidebar Environment Variables", () => {
const defaultProps = {
connectionStatus: "disconnected" as const,
@ -53,6 +72,7 @@ describe("Sidebar Environment Variables", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
describe("Basic Operations", () => {
@ -622,4 +642,231 @@ describe("Sidebar Environment Variables", () => {
);
});
});
describe("Copy Configuration Features", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
const getCopyButtons = () => {
return {
serverEntry: screen.getByRole("button", { name: /server entry/i }),
serversFile: screen.getByRole("button", { name: /servers file/i }),
};
};
it("should render both copy buttons for all transport types", () => {
["stdio", "sse", "streamable-http"].forEach((transportType) => {
renderSidebar({ transportType });
// There should be exactly one Server Entry and one Servers File button per render
const serverEntryButtons = screen.getAllByRole("button", {
name: /server entry/i,
});
const serversFileButtons = screen.getAllByRole("button", {
name: /servers file/i,
});
expect(serverEntryButtons).toHaveLength(1);
expect(serversFileButtons).toHaveLength(1);
// Clean up DOM for next iteration
// (Testing Library's render does not auto-unmount in a loop)
document.body.innerHTML = "";
});
});
it("should copy server entry configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: ["--inspect", "server.js"],
env,
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
command,
args: ["--inspect", "server.js"],
env,
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should handle empty args in STDIO transport", async () => {
const command = "python";
const args = "";
renderSidebar({
transportType: "stdio",
command,
args,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: [],
env: {},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
});
});

View file

@ -1,4 +1,4 @@
import { useToast } from "@/hooks/use-toast";
import { useToast } from "@/lib/hooks/useToast";
import {
Toast,
ToastClose,

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,
OAuthTokens,
OAuthTokensSchema,
OAuthClientMetadata,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(private serverUrl: string) {
constructor(public serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
@ -17,7 +19,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
return window.location.origin + "/oauth/callback";
}
get clientMetadata() {
get clientMetadata(): OAuthClientMetadata {
return {
redirect_uris: [this.redirectUrl],
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",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
SERVER_METADATA: "mcp_server_metadata",
} as const;
// Generate server-specific session storage keys

View file

@ -45,7 +45,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
}));
// Mock the toast hook
jest.mock("@/hooks/use-toast", () => ({
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: jest.fn(),
}),

View file

@ -2,8 +2,12 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
SSEClientTransport,
SseError,
SSEClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
ClientNotification,
ClientRequest,
@ -27,7 +31,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
@ -279,29 +283,6 @@ export function useConnection({
setConnectionStatus("error-connecting-to-proxy");
return;
}
let mcpProxyServerUrl;
switch (transportType) {
case "stdio":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
try {
// Inject auth manually instead of using SSEClientTransport, because we're
@ -320,21 +301,82 @@ export function useConnection({
}
// Create appropriate transport
const transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
let transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions;
let mcpProxyServerUrl;
switch (transportType) {
case "stdio":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
const clientTransport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);

View file

@ -0,0 +1,52 @@
import { useCallback, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system";
const useTheme = (): [Theme, (mode: Theme) => void] => {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem("theme") as Theme;
return savedTheme || "system";
});
useEffect(() => {
const darkModeMediaQuery = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const handleDarkModeChange = (e: MediaQueryListEvent) => {
if (theme === "system") {
updateDocumentTheme(e.matches ? "dark" : "light");
}
};
const updateDocumentTheme = (newTheme: "light" | "dark") => {
document.documentElement.classList.toggle("dark", newTheme === "dark");
};
// Set initial theme based on current mode
if (theme === "system") {
updateDocumentTheme(darkModeMediaQuery.matches ? "dark" : "light");
} else {
updateDocumentTheme(theme);
}
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
return () => {
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
};
}, [theme]);
const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
if (newTheme !== "system") {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []);
return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
};
export default useTheme;

View file

@ -0,0 +1,191 @@
"use client";
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
const enum ActionType {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
type Action =
| {
type: ActionType.ADD_TOAST;
toast: ToasterToast;
}
| {
type: ActionType.UPDATE_TOAST;
toast: Partial<ToasterToast>;
}
| {
type: ActionType.DISMISS_TOAST;
toastId?: ToasterToast["id"];
}
| {
type: ActionType.REMOVE_TOAST;
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: ActionType.REMOVE_TOAST,
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case ActionType.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case ActionType.DISMISS_TOAST: {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: ActionType.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
dispatch({
type: ActionType.ADD_TOAST,
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
};
}
export { useToast, toast };

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);
}
}

Some files were not shown because too many files have changed in this diff Show more