Merge branch 'main' into fix-port-numbers
This commit is contained in:
commit
eb190b637d
21 changed files with 4913 additions and 442 deletions
|
|
@ -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>
|
||||
|
|
|
|||
272
client/src/components/AuthDebugger.tsx
Normal file
272
client/src/components/AuthDebugger.tsx
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
92
client/src/components/OAuthDebugCallback.tsx
Normal file
92
client/src/components/OAuthDebugCallback.tsx
Normal 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;
|
||||
259
client/src/components/OAuthFlowProgress.tsx
Normal file
259
client/src/components/OAuthFlowProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
441
client/src/components/__tests__/AuthDebugger.test.tsx
Normal file
441
client/src/components/__tests__/AuthDebugger.test.tsx
Normal 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="),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useToast } from "@/lib/hooks/useToast";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
|
|
|
|||
38
client/src/lib/auth-types.ts
Normal file
38
client/src/lib/auth-types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
52
client/src/lib/hooks/useTheme.ts
Normal file
52
client/src/lib/hooks/useTheme.ts
Normal 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;
|
||||
191
client/src/lib/hooks/useToast.ts
Normal file
191
client/src/lib/hooks/useToast.ts
Normal 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 };
|
||||
181
client/src/lib/oauth-state-machine.ts
Normal file
181
client/src/lib/oauth-state-machine.ts
Normal 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
Loading…
Reference in a new issue