Merge branch 'main' into main
This commit is contained in:
commit
d758e8be93
11 changed files with 819 additions and 601 deletions
|
|
@ -34,7 +34,6 @@ import {
|
||||||
useDraggablePane,
|
useDraggablePane,
|
||||||
useDraggableSidebar,
|
useDraggableSidebar,
|
||||||
} from "./lib/hooks/useDraggablePane";
|
} from "./lib/hooks/useDraggablePane";
|
||||||
import { StdErrNotification } from "./lib/notificationTypes";
|
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -106,9 +105,6 @@ const App = () => {
|
||||||
>(getInitialTransportType);
|
>(getInitialTransportType);
|
||||||
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
|
||||||
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
|
||||||
const [stdErrNotifications, setStdErrNotifications] = useState<
|
|
||||||
StdErrNotification[]
|
|
||||||
>([]);
|
|
||||||
const [roots, setRoots] = useState<Root[]>([]);
|
const [roots, setRoots] = useState<Root[]>([]);
|
||||||
const [env, setEnv] = useState<Record<string, string>>({});
|
const [env, setEnv] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
|
@ -224,12 +220,6 @@ const App = () => {
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
},
|
},
|
||||||
onStdErrNotification: (notification) => {
|
|
||||||
setStdErrNotifications((prev) => [
|
|
||||||
...prev,
|
|
||||||
notification as StdErrNotification,
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
onPendingRequest: (request, resolve, reject) => {
|
onPendingRequest: (request, resolve, reject) => {
|
||||||
setPendingSampleRequests((prev) => [
|
setPendingSampleRequests((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -757,10 +747,6 @@ const App = () => {
|
||||||
setLogLevel(level);
|
setLogLevel(level);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearStdErrNotifications = () => {
|
|
||||||
setStdErrNotifications([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthDebuggerWrapper = () => (
|
const AuthDebuggerWrapper = () => (
|
||||||
<TabsContent value="auth">
|
<TabsContent value="auth">
|
||||||
<AuthDebugger
|
<AuthDebugger
|
||||||
|
|
@ -829,11 +815,9 @@ const App = () => {
|
||||||
setOauthScope={setOauthScope}
|
setOauthScope={setOauthScope}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
onDisconnect={disconnectMcpServer}
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
|
||||||
logLevel={logLevel}
|
logLevel={logLevel}
|
||||||
sendLogLevelRequest={sendLogLevelRequest}
|
sendLogLevelRequest={sendLogLevelRequest}
|
||||||
loggingSupported={!!serverCapabilities?.logging || false}
|
loggingSupported={!!serverCapabilities?.logging || false}
|
||||||
clearStdErrNotifications={clearStdErrNotifications}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onMouseDown={handleSidebarDragStart}
|
onMouseDown={handleSidebarDragStart}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types";
|
||||||
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
import { OAuthFlowProgress } from "./OAuthFlowProgress";
|
||||||
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
import { OAuthStateMachine } from "../lib/oauth-state-machine";
|
||||||
import { SESSION_KEYS } from "../lib/constants";
|
import { SESSION_KEYS } from "../lib/constants";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
|
||||||
export interface AuthDebuggerProps {
|
export interface AuthDebuggerProps {
|
||||||
serverUrl: string;
|
serverUrl: string;
|
||||||
|
|
@ -163,6 +164,23 @@ const AuthDebugger = ({
|
||||||
currentState.oauthStep === "authorization_code" &&
|
currentState.oauthStep === "authorization_code" &&
|
||||||
currentState.authorizationUrl
|
currentState.authorizationUrl
|
||||||
) {
|
) {
|
||||||
|
// Validate the URL before redirecting
|
||||||
|
try {
|
||||||
|
validateRedirectUrl(currentState.authorizationUrl);
|
||||||
|
} catch (error) {
|
||||||
|
updateAuthState({
|
||||||
|
...currentState,
|
||||||
|
isInitiatingAuth: false,
|
||||||
|
latestError:
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
statusMessage: {
|
||||||
|
type: "error",
|
||||||
|
message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store the current auth state before redirecting
|
// Store the current auth state before redirecting
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
SESSION_KEYS.AUTH_DEBUGGER_STATE,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { Button } from "./ui/button";
|
||||||
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
|
|
||||||
interface OAuthStepProps {
|
interface OAuthStepProps {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -71,6 +73,7 @@ export const OAuthFlowProgress = ({
|
||||||
updateAuthState,
|
updateAuthState,
|
||||||
proceedToNextStep,
|
proceedToNextStep,
|
||||||
}: OAuthFlowProgressProps) => {
|
}: OAuthFlowProgressProps) => {
|
||||||
|
const { toast } = useToast();
|
||||||
const provider = useMemo(
|
const provider = useMemo(
|
||||||
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
() => new DebugInspectorOAuthClientProvider(serverUrl),
|
||||||
[serverUrl],
|
[serverUrl],
|
||||||
|
|
@ -239,16 +242,32 @@ export const OAuthFlowProgress = ({
|
||||||
<p className="text-xs break-all">
|
<p className="text-xs break-all">
|
||||||
{authState.authorizationUrl}
|
{authState.authorizationUrl}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<button
|
||||||
href={authState.authorizationUrl}
|
onClick={() => {
|
||||||
target="_blank"
|
try {
|
||||||
rel="noopener noreferrer"
|
validateRedirectUrl(authState.authorizationUrl!);
|
||||||
|
window.open(
|
||||||
|
authState.authorizationUrl!,
|
||||||
|
"_blank",
|
||||||
|
"noopener noreferrer",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Invalid URL",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The authorization URL is not valid",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex items-center text-blue-500 hover:text-blue-700"
|
className="flex items-center text-blue-500 hover:text-blue-700"
|
||||||
aria-label="Open authorization URL in new tab"
|
aria-label="Open authorization URL in new tab"
|
||||||
title="Open authorization URL"
|
title="Open authorization URL"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
Click the link to authorize in your browser. After
|
Click the link to authorize in your browser. After
|
||||||
|
|
@ -354,7 +373,21 @@ export const OAuthFlowProgress = ({
|
||||||
authState.authorizationUrl && (
|
authState.authorizationUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
|
onClick={() => {
|
||||||
|
try {
|
||||||
|
validateRedirectUrl(authState.authorizationUrl!);
|
||||||
|
window.open(authState.authorizationUrl!, "_blank");
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Invalid URL",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "The authorization URL is not valid",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Open in New Tab
|
Open in New Tab
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { StdErrNotification } from "@/lib/notificationTypes";
|
|
||||||
import {
|
import {
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
LoggingLevelSchema,
|
LoggingLevelSchema,
|
||||||
|
|
@ -62,8 +61,6 @@ interface SidebarProps {
|
||||||
setOauthScope: (scope: string) => void;
|
setOauthScope: (scope: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
|
||||||
clearStdErrNotifications: () => void;
|
|
||||||
logLevel: LoggingLevel;
|
logLevel: LoggingLevel;
|
||||||
sendLogLevelRequest: (level: LoggingLevel) => void;
|
sendLogLevelRequest: (level: LoggingLevel) => void;
|
||||||
loggingSupported: boolean;
|
loggingSupported: boolean;
|
||||||
|
|
@ -93,8 +90,6 @@ const Sidebar = ({
|
||||||
setOauthScope,
|
setOauthScope,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
stdErrNotifications,
|
|
||||||
clearStdErrNotifications,
|
|
||||||
logLevel,
|
logLevel,
|
||||||
sendLogLevelRequest,
|
sendLogLevelRequest,
|
||||||
loggingSupported,
|
loggingSupported,
|
||||||
|
|
@ -268,6 +263,7 @@ const Sidebar = ({
|
||||||
placeholder="Command"
|
placeholder="Command"
|
||||||
value={command}
|
value={command}
|
||||||
onChange={(e) => setCommand(e.target.value)}
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
onBlur={(e) => setCommand(e.target.value.trim())}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -760,36 +756,6 @@ const Sidebar = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stdErrNotifications.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mt-4 border-t border-gray-200 pt-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<h3 className="text-sm font-medium">
|
|
||||||
Error output from MCP server
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearStdErrNotifications}
|
|
||||||
className="h-8 px-2"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 max-h-80 overflow-y-auto">
|
|
||||||
{stdErrNotifications.map((notification, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="text-sm text-red-500 font-mono py-2 border-b border-gray-200 last:border-b-0"
|
|
||||||
>
|
|
||||||
{notification.params.content}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ Object.defineProperty(navigator, "clipboard", {
|
||||||
// Setup fake timers
|
// Setup fake timers
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
|
||||||
describe("Sidebar Environment Variables", () => {
|
describe("Sidebar", () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
connectionStatus: "disconnected" as const,
|
connectionStatus: "disconnected" as const,
|
||||||
transportType: "stdio" as const,
|
transportType: "stdio" as const,
|
||||||
|
|
@ -69,16 +69,49 @@ describe("Sidebar Environment Variables", () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEnvVarsSection = () => {
|
|
||||||
const button = screen.getByTestId("env-vars-button");
|
|
||||||
fireEvent.click(button);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
jest.clearAllTimers();
|
jest.clearAllTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Command and arguments", () => {
|
||||||
|
it("should trim whitespace from command input on blur", () => {
|
||||||
|
const setCommand = jest.fn();
|
||||||
|
renderSidebar({ command: " node server.js ", setCommand });
|
||||||
|
|
||||||
|
const commandInput = screen.getByLabelText("Command");
|
||||||
|
|
||||||
|
fireEvent.blur(commandInput);
|
||||||
|
expect(setCommand).toHaveBeenLastCalledWith("node server.js");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace-only command input on blur", () => {
|
||||||
|
const setCommand = jest.fn();
|
||||||
|
renderSidebar({ command: " ", setCommand });
|
||||||
|
|
||||||
|
const commandInput = screen.getByLabelText("Command");
|
||||||
|
|
||||||
|
fireEvent.blur(commandInput);
|
||||||
|
expect(setCommand).toHaveBeenLastCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect command without surrounding whitespace", () => {
|
||||||
|
const setCommand = jest.fn();
|
||||||
|
renderSidebar({ command: "node", setCommand });
|
||||||
|
|
||||||
|
const commandInput = screen.getByLabelText("Command");
|
||||||
|
|
||||||
|
fireEvent.blur(commandInput);
|
||||||
|
expect(setCommand).toHaveBeenLastCalledWith("node");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Environment Variables", () => {
|
||||||
|
const openEnvVarsSection = () => {
|
||||||
|
const button = screen.getByTestId("env-vars-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
describe("Basic Operations", () => {
|
describe("Basic Operations", () => {
|
||||||
it("should add a new environment variable", () => {
|
it("should add a new environment variable", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
|
|
@ -127,164 +160,15 @@ describe("Sidebar Environment Variables", () => {
|
||||||
const valueInput = screen.getByDisplayValue("test_value");
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
expect(valueInput).toHaveProperty("type", "password");
|
expect(valueInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
const toggleButton = screen.getByRole("button", {
|
||||||
|
name: /show value/i,
|
||||||
|
});
|
||||||
fireEvent.click(toggleButton);
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
expect(valueInput).toHaveProperty("type", "text");
|
expect(valueInput).toHaveProperty("type", "text");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Authentication", () => {
|
|
||||||
const openAuthSection = () => {
|
|
||||||
const button = screen.getByTestId("auth-button");
|
|
||||||
fireEvent.click(button);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should update bearer token", () => {
|
|
||||||
const setBearerToken = jest.fn();
|
|
||||||
renderSidebar({
|
|
||||||
bearerToken: "",
|
|
||||||
setBearerToken,
|
|
||||||
transportType: "sse", // Set transport type to SSE
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
|
||||||
|
|
||||||
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update header name", () => {
|
|
||||||
const setHeaderName = jest.fn();
|
|
||||||
renderSidebar({
|
|
||||||
headerName: "Authorization",
|
|
||||||
setHeaderName,
|
|
||||||
transportType: "sse",
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
|
||||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
|
||||||
|
|
||||||
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should clear bearer token", () => {
|
|
||||||
const setBearerToken = jest.fn();
|
|
||||||
renderSidebar({
|
|
||||||
bearerToken: "existing_token",
|
|
||||||
setBearerToken,
|
|
||||||
transportType: "sse", // Set transport type to SSE
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
|
||||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
|
||||||
|
|
||||||
expect(setBearerToken).toHaveBeenCalledWith("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly render bearer token input", () => {
|
|
||||||
const { rerender } = renderSidebar({
|
|
||||||
bearerToken: "existing_token",
|
|
||||||
transportType: "sse", // Set transport type to SSE
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
// Token input should be a password field
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
|
||||||
expect(tokenInput).toHaveProperty("type", "password");
|
|
||||||
|
|
||||||
// Update the token
|
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
|
||||||
|
|
||||||
// Rerender with updated token
|
|
||||||
rerender(
|
|
||||||
<TooltipProvider>
|
|
||||||
<Sidebar
|
|
||||||
{...defaultProps}
|
|
||||||
bearerToken="new_token"
|
|
||||||
transportType="sse"
|
|
||||||
/>
|
|
||||||
</TooltipProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Token input should still exist after update
|
|
||||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain token visibility state after update", () => {
|
|
||||||
const { rerender } = renderSidebar({
|
|
||||||
bearerToken: "existing_token",
|
|
||||||
transportType: "sse", // Set transport type to SSE
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
// Token input should be a password field
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
|
||||||
expect(tokenInput).toHaveProperty("type", "password");
|
|
||||||
|
|
||||||
// Update the token
|
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
|
||||||
|
|
||||||
// Rerender with updated token
|
|
||||||
rerender(
|
|
||||||
<TooltipProvider>
|
|
||||||
<Sidebar
|
|
||||||
{...defaultProps}
|
|
||||||
bearerToken="new_token"
|
|
||||||
transportType="sse"
|
|
||||||
/>
|
|
||||||
</TooltipProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Token input should still exist after update
|
|
||||||
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain header name when toggling auth section", () => {
|
|
||||||
renderSidebar({
|
|
||||||
headerName: "X-API-Key",
|
|
||||||
transportType: "sse",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Open auth section
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
// Verify header name is displayed
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
|
||||||
expect(headerInput).toHaveValue("X-API-Key");
|
|
||||||
|
|
||||||
// Close auth section
|
|
||||||
const authButton = screen.getByTestId("auth-button");
|
|
||||||
fireEvent.click(authButton);
|
|
||||||
|
|
||||||
// Reopen auth section
|
|
||||||
fireEvent.click(authButton);
|
|
||||||
|
|
||||||
// Verify header name is still preserved
|
|
||||||
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should display default header name when not specified", () => {
|
|
||||||
renderSidebar({
|
|
||||||
headerName: undefined,
|
|
||||||
transportType: "sse",
|
|
||||||
});
|
|
||||||
|
|
||||||
openAuthSection();
|
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
|
||||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Key Editing", () => {
|
describe("Key Editing", () => {
|
||||||
it("should maintain order when editing first key", () => {
|
it("should maintain order when editing first key", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
|
|
@ -319,7 +203,9 @@ describe("Sidebar Environment Variables", () => {
|
||||||
openEnvVarsSection();
|
openEnvVarsSection();
|
||||||
|
|
||||||
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
const middleKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
fireEvent.change(middleKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
fireEvent.change(middleKeyInput, {
|
||||||
|
target: { value: "NEW_SECOND_KEY" },
|
||||||
|
});
|
||||||
|
|
||||||
expect(setEnv).toHaveBeenCalledWith({
|
expect(setEnv).toHaveBeenCalledWith({
|
||||||
FIRST_KEY: "first_value",
|
FIRST_KEY: "first_value",
|
||||||
|
|
@ -406,7 +292,9 @@ describe("Sidebar Environment Variables", () => {
|
||||||
|
|
||||||
// Second key edit
|
// Second key edit
|
||||||
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
const secondKeyInput = screen.getByDisplayValue("SECOND_KEY");
|
||||||
fireEvent.change(secondKeyInput, { target: { value: "NEW_SECOND_KEY" } });
|
fireEvent.change(secondKeyInput, {
|
||||||
|
target: { value: "NEW_SECOND_KEY" },
|
||||||
|
});
|
||||||
|
|
||||||
// Verify the final state matches what we expect
|
// Verify the final state matches what we expect
|
||||||
expect(setEnv).toHaveBeenLastCalledWith({
|
expect(setEnv).toHaveBeenLastCalledWith({
|
||||||
|
|
@ -422,7 +310,9 @@ describe("Sidebar Environment Variables", () => {
|
||||||
openEnvVarsSection();
|
openEnvVarsSection();
|
||||||
|
|
||||||
// Show the value
|
// Show the value
|
||||||
const toggleButton = screen.getByRole("button", { name: /show value/i });
|
const toggleButton = screen.getByRole("button", {
|
||||||
|
name: /show value/i,
|
||||||
|
});
|
||||||
fireEvent.click(toggleButton);
|
fireEvent.click(toggleButton);
|
||||||
|
|
||||||
const valueInput = screen.getByDisplayValue("test_value");
|
const valueInput = screen.getByDisplayValue("test_value");
|
||||||
|
|
@ -472,7 +362,7 @@ describe("Sidebar Environment Variables", () => {
|
||||||
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
|
expect(setEnv).toHaveBeenCalledWith({ "TEST-KEY@123": "test_value" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle unicode characters", () => {
|
it("should handle unicode characters in key", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
const initialEnv = { TEST_KEY: "test_value" };
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
renderSidebar({ env: initialEnv, setEnv });
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
@ -485,7 +375,7 @@ describe("Sidebar Environment Variables", () => {
|
||||||
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
|
expect(setEnv).toHaveBeenCalledWith({ "TEST_🔑": "test_value" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle very long key names", () => {
|
it("should handle a very long key name", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
const initialEnv = { TEST_KEY: "test_value" };
|
const initialEnv = { TEST_KEY: "test_value" };
|
||||||
renderSidebar({ env: initialEnv, setEnv });
|
renderSidebar({ env: initialEnv, setEnv });
|
||||||
|
|
@ -499,165 +389,9 @@ describe("Sidebar Environment Variables", () => {
|
||||||
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
|
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Configuration Operations", () => {
|
|
||||||
const openConfigSection = () => {
|
|
||||||
const button = screen.getByTestId("config-button");
|
|
||||||
fireEvent.click(button);
|
|
||||||
};
|
|
||||||
|
|
||||||
it("should update MCP server request timeout", () => {
|
|
||||||
const setConfig = jest.fn();
|
|
||||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
|
||||||
|
|
||||||
openConfigSection();
|
|
||||||
|
|
||||||
const timeoutInput = screen.getByTestId(
|
|
||||||
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
|
||||||
label: "Request Timeout",
|
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
|
||||||
value: 5000,
|
|
||||||
is_session_item: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update MCP server proxy address", () => {
|
|
||||||
const setConfig = jest.fn();
|
|
||||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
|
||||||
|
|
||||||
openConfigSection();
|
|
||||||
|
|
||||||
const proxyAddressInput = screen.getByTestId(
|
|
||||||
"MCP_PROXY_FULL_ADDRESS-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(proxyAddressInput, {
|
|
||||||
target: { value: "http://localhost:8080" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
MCP_PROXY_FULL_ADDRESS: {
|
|
||||||
label: "Inspector Proxy Address",
|
|
||||||
description:
|
|
||||||
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
|
||||||
value: "http://localhost:8080",
|
|
||||||
is_session_item: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should update max total timeout", () => {
|
|
||||||
const setConfig = jest.fn();
|
|
||||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
|
||||||
|
|
||||||
openConfigSection();
|
|
||||||
|
|
||||||
const maxTotalTimeoutInput = screen.getByTestId(
|
|
||||||
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(maxTotalTimeoutInput, {
|
|
||||||
target: { value: "10000" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
|
||||||
label: "Maximum Total Timeout",
|
|
||||||
description:
|
|
||||||
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
|
||||||
value: 10000,
|
|
||||||
is_session_item: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle invalid timeout values entered by user", () => {
|
|
||||||
const setConfig = jest.fn();
|
|
||||||
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
|
||||||
|
|
||||||
openConfigSection();
|
|
||||||
|
|
||||||
const timeoutInput = screen.getByTestId(
|
|
||||||
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
|
||||||
|
|
||||||
expect(setConfig).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
|
||||||
label: "Request Timeout",
|
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
|
||||||
value: 0,
|
|
||||||
is_session_item: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should maintain configuration state after multiple updates", () => {
|
|
||||||
const setConfig = jest.fn();
|
|
||||||
const { rerender } = renderSidebar({
|
|
||||||
config: DEFAULT_INSPECTOR_CONFIG,
|
|
||||||
setConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
openConfigSection();
|
|
||||||
// First update
|
|
||||||
const timeoutInput = screen.getByTestId(
|
|
||||||
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
|
||||||
|
|
||||||
// Get the updated config from the first setConfig call
|
|
||||||
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
|
|
||||||
|
|
||||||
// Rerender with the updated config
|
|
||||||
rerender(
|
|
||||||
<TooltipProvider>
|
|
||||||
<Sidebar
|
|
||||||
{...defaultProps}
|
|
||||||
config={updatedConfig}
|
|
||||||
setConfig={setConfig}
|
|
||||||
/>
|
|
||||||
</TooltipProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Second update
|
|
||||||
const updatedTimeoutInput = screen.getByTestId(
|
|
||||||
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
|
||||||
);
|
|
||||||
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
|
||||||
|
|
||||||
// Verify the final state matches what we expect
|
|
||||||
expect(setConfig).toHaveBeenLastCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
MCP_SERVER_REQUEST_TIMEOUT: {
|
|
||||||
label: "Request Timeout",
|
|
||||||
description: "Timeout for requests to the MCP server (ms)",
|
|
||||||
value: 3000,
|
|
||||||
is_session_item: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Copy Configuration Features", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.clearAllTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Copy Server Features", () => {
|
||||||
const getCopyButtons = () => {
|
const getCopyButtons = () => {
|
||||||
return {
|
return {
|
||||||
serverEntry: screen.getByRole("button", { name: /server entry/i }),
|
serverEntry: screen.getByRole("button", { name: /server entry/i }),
|
||||||
|
|
@ -878,4 +612,307 @@ describe("Sidebar Environment Variables", () => {
|
||||||
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
const openAuthSection = () => {
|
||||||
|
const button = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update header name", () => {
|
||||||
|
const setHeaderName = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "Authorization",
|
||||||
|
setHeaderName,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||||
|
|
||||||
|
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly render bearer token input", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain token visibility state after update", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain header name when toggling auth section", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "X-API-Key",
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open auth section
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Verify header name is displayed
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveValue("X-API-Key");
|
||||||
|
|
||||||
|
// Close auth section
|
||||||
|
const authButton = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Reopen auth section
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Verify header name is still preserved
|
||||||
|
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display default header name when not specified", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: undefined,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Configuration", () => {
|
||||||
|
const openConfigSection = () => {
|
||||||
|
const button = screen.getByTestId("config-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update MCP server request timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 5000,
|
||||||
|
is_session_item: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update MCP server proxy address", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const proxyAddressInput = screen.getByTestId(
|
||||||
|
"MCP_PROXY_FULL_ADDRESS-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(proxyAddressInput, {
|
||||||
|
target: { value: "http://localhost:8080" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_PROXY_FULL_ADDRESS: {
|
||||||
|
label: "Inspector Proxy Address",
|
||||||
|
description:
|
||||||
|
"Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577",
|
||||||
|
value: "http://localhost:8080",
|
||||||
|
is_session_item: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update max total timeout", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const maxTotalTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_REQUEST_MAX_TOTAL_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(maxTotalTimeoutInput, {
|
||||||
|
target: { value: "10000" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_REQUEST_MAX_TOTAL_TIMEOUT: {
|
||||||
|
label: "Maximum Total Timeout",
|
||||||
|
description:
|
||||||
|
"Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||||
|
value: 10000,
|
||||||
|
is_session_item: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle invalid timeout values entered by user", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
renderSidebar({ config: DEFAULT_INSPECTOR_CONFIG, setConfig });
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "abc1" } });
|
||||||
|
|
||||||
|
expect(setConfig).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 0,
|
||||||
|
is_session_item: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain configuration state after multiple updates", () => {
|
||||||
|
const setConfig = jest.fn();
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
config: DEFAULT_INSPECTOR_CONFIG,
|
||||||
|
setConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
openConfigSection();
|
||||||
|
// First update
|
||||||
|
const timeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(timeoutInput, { target: { value: "5000" } });
|
||||||
|
|
||||||
|
// Get the updated config from the first setConfig call
|
||||||
|
const updatedConfig = setConfig.mock.calls[0][0] as InspectorConfig;
|
||||||
|
|
||||||
|
// Rerender with the updated config
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
config={updatedConfig}
|
||||||
|
setConfig={setConfig}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second update
|
||||||
|
const updatedTimeoutInput = screen.getByTestId(
|
||||||
|
"MCP_SERVER_REQUEST_TIMEOUT-input",
|
||||||
|
);
|
||||||
|
fireEvent.change(updatedTimeoutInput, { target: { value: "3000" } });
|
||||||
|
|
||||||
|
// Verify the final state matches what we expect
|
||||||
|
expect(setConfig).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
MCP_SERVER_REQUEST_TIMEOUT: {
|
||||||
|
label: "Request Timeout",
|
||||||
|
description: "Timeout for requests to the MCP server (ms)",
|
||||||
|
value: 3000,
|
||||||
|
is_session_item: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
|
||||||
import { generateOAuthState } from "@/utils/oauthUtils";
|
import { generateOAuthState } from "@/utils/oauthUtils";
|
||||||
|
import { validateRedirectUrl } from "@/utils/urlValidation";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
|
* Discovers OAuth scopes from server metadata, with preference for resource metadata scopes
|
||||||
|
|
@ -182,12 +183,8 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectToAuthorization(authorizationUrl: URL) {
|
redirectToAuthorization(authorizationUrl: URL) {
|
||||||
if (
|
// Validate the URL using the shared utility
|
||||||
authorizationUrl.protocol !== "http:" &&
|
validateRedirectUrl(authorizationUrl.href);
|
||||||
authorizationUrl.protocol !== "https:"
|
|
||||||
) {
|
|
||||||
throw new Error("Authorization URL must be HTTP or HTTPS");
|
|
||||||
}
|
|
||||||
window.location.href = authorizationUrl.href;
|
window.location.href = authorizationUrl.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { useEffect, useState } from "react";
|
||||||
import { useToast } from "@/lib/hooks/useToast";
|
import { useToast } from "@/lib/hooks/useToast";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConnectionStatus } from "../constants";
|
import { ConnectionStatus } from "../constants";
|
||||||
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
|
import { Notification } from "../notificationTypes";
|
||||||
import {
|
import {
|
||||||
auth,
|
auth,
|
||||||
discoverOAuthProtectedResourceMetadata,
|
discoverOAuthProtectedResourceMetadata,
|
||||||
|
|
@ -92,7 +92,6 @@ export function useConnection({
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
onNotification,
|
onNotification,
|
||||||
onStdErrNotification,
|
|
||||||
onPendingRequest,
|
onPendingRequest,
|
||||||
onElicitationRequest,
|
onElicitationRequest,
|
||||||
getRoots,
|
getRoots,
|
||||||
|
|
@ -505,13 +504,6 @@ export function useConnection({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onStdErrNotification) {
|
|
||||||
client.setNotificationHandler(
|
|
||||||
StdErrNotificationSchema,
|
|
||||||
onStdErrNotification,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let capabilities;
|
let capabilities;
|
||||||
try {
|
try {
|
||||||
const transport =
|
const transport =
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,8 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const StdErrNotificationSchema = BaseNotificationSchema.extend({
|
|
||||||
method: z.literal("notifications/stderr"),
|
|
||||||
params: z.object({
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const NotificationSchema = ClientNotificationSchema.or(
|
export const NotificationSchema = ClientNotificationSchema.or(
|
||||||
StdErrNotificationSchema,
|
ServerNotificationSchema,
|
||||||
)
|
).or(BaseNotificationSchema);
|
||||||
.or(ServerNotificationSchema)
|
|
||||||
.or(BaseNotificationSchema);
|
|
||||||
|
|
||||||
export type StdErrNotification = z.infer<typeof StdErrNotificationSchema>;
|
|
||||||
export type Notification = z.infer<typeof NotificationSchema>;
|
export type Notification = z.infer<typeof NotificationSchema>;
|
||||||
|
|
|
||||||
135
client/src/utils/__tests__/urlValidation.test.ts
Normal file
135
client/src/utils/__tests__/urlValidation.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { validateRedirectUrl } from "../urlValidation";
|
||||||
|
|
||||||
|
describe("validateRedirectUrl", () => {
|
||||||
|
describe("valid URLs", () => {
|
||||||
|
it("should allow HTTP URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("http://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow HTTPS URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("https://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with ports", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com:8080"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with paths", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com/path/to/auth"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow URLs with query parameters", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://example.com?param=value"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid URLs - XSS vectors", () => {
|
||||||
|
it("should block javascript: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("javascript:alert('XSS')")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block javascript: with encoded characters", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("javascript:alert%28%27XSS%27%29"),
|
||||||
|
).toThrow("Authorization URL must be HTTP or HTTPS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block data: protocol", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("data:text/html,<script>alert('XSS')</script>"),
|
||||||
|
).toThrow("Authorization URL must be HTTP or HTTPS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block vbscript: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("vbscript:msgbox")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block file: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("file:///etc/passwd")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block about: protocol", () => {
|
||||||
|
expect(() => validateRedirectUrl("about:blank")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should block custom protocols", () => {
|
||||||
|
expect(() => validateRedirectUrl("custom://example")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle malformed URLs", () => {
|
||||||
|
expect(() => validateRedirectUrl("not a url")).toThrow(
|
||||||
|
"Invalid URL: not a url",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
expect(() => validateRedirectUrl("")).toThrow("Invalid URL: ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with unicode characters", () => {
|
||||||
|
expect(() => validateRedirectUrl("https://例え.jp")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with case variations", () => {
|
||||||
|
expect(() => validateRedirectUrl("HTTPS://EXAMPLE.COM")).not.toThrow();
|
||||||
|
expect(() => validateRedirectUrl("HtTpS://example.com")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle protocol-relative URLs as invalid", () => {
|
||||||
|
expect(() => validateRedirectUrl("//example.com")).toThrow(
|
||||||
|
"Invalid URL: //example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle URLs with authentication", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("https://user:pass@example.com"),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security considerations", () => {
|
||||||
|
it("should not be fooled by whitespace", () => {
|
||||||
|
expect(() => validateRedirectUrl(" javascript:alert('XSS')")).toThrow();
|
||||||
|
expect(() => validateRedirectUrl("javascript:alert('XSS') ")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null bytes", () => {
|
||||||
|
expect(() =>
|
||||||
|
validateRedirectUrl("java\x00script:alert('XSS')"),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle tab characters", () => {
|
||||||
|
expect(() => validateRedirectUrl("java\tscript:alert('XSS')")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle newlines", () => {
|
||||||
|
expect(() => validateRedirectUrl("java\nscript:alert('XSS')")).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed case protocols", () => {
|
||||||
|
expect(() => validateRedirectUrl("JaVaScRiPt:alert('XSS')")).toThrow(
|
||||||
|
"Authorization URL must be HTTP or HTTPS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
24
client/src/utils/urlValidation.ts
Normal file
24
client/src/utils/urlValidation.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Validates that a URL is safe for redirection.
|
||||||
|
* Only allows HTTP and HTTPS protocols to prevent XSS attacks.
|
||||||
|
*
|
||||||
|
* @param url - The URL string to validate
|
||||||
|
* @throws Error if the URL has an unsafe protocol
|
||||||
|
*/
|
||||||
|
export function validateRedirectUrl(url: string): void {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||||
|
throw new Error("Authorization URL must be HTTP or HTTPS");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message === "Authorization URL must be HTTP or HTTPS"
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// If URL parsing fails, it's also invalid
|
||||||
|
throw new Error(`Invalid URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -181,7 +181,7 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
|
||||||
const transportType = query.transportType as string;
|
const transportType = query.transportType as string;
|
||||||
|
|
||||||
if (transportType === "stdio") {
|
if (transportType === "stdio") {
|
||||||
const command = query.command as string;
|
const command = (query.command as string).trim();
|
||||||
const origArgs = shellParseArgs(query.args as string) as string[];
|
const origArgs = shellParseArgs(query.args as string) as string[];
|
||||||
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
const queryEnv = query.env ? JSON.parse(query.env as string) : {};
|
||||||
const env = { ...defaultEnvironment, ...process.env, ...queryEnv };
|
const env = { ...defaultEnvironment, ...process.env, ...queryEnv };
|
||||||
|
|
@ -401,24 +401,66 @@ app.get(
|
||||||
|
|
||||||
(serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => {
|
(serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => {
|
||||||
if (chunk.toString().includes("MODULE_NOT_FOUND")) {
|
if (chunk.toString().includes("MODULE_NOT_FOUND")) {
|
||||||
|
// Server command not found, remove transports
|
||||||
|
const message = "Command not found, transports removed";
|
||||||
webAppTransport.send({
|
webAppTransport.send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "notifications/stderr",
|
method: "notifications/message",
|
||||||
params: {
|
params: {
|
||||||
content: "Command not found, transports removed",
|
level: "emergency",
|
||||||
|
logger: "proxy",
|
||||||
|
data: {
|
||||||
|
message,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
webAppTransport.close();
|
webAppTransport.close();
|
||||||
serverTransport.close();
|
serverTransport.close();
|
||||||
webAppTransports.delete(webAppTransport.sessionId);
|
webAppTransports.delete(webAppTransport.sessionId);
|
||||||
serverTransports.delete(webAppTransport.sessionId);
|
serverTransports.delete(webAppTransport.sessionId);
|
||||||
console.error("Command not found, transports removed");
|
console.error(message);
|
||||||
} else {
|
} else {
|
||||||
|
// Inspect message and attempt to assign a RFC 5424 Syslog Protocol level
|
||||||
|
let level;
|
||||||
|
let message = chunk.toString().trim();
|
||||||
|
let ucMsg = chunk.toString().toUpperCase();
|
||||||
|
if (ucMsg.includes("DEBUG")) {
|
||||||
|
level = "debug";
|
||||||
|
} else if (ucMsg.includes("INFO")) {
|
||||||
|
level = "info";
|
||||||
|
} else if (ucMsg.includes("NOTICE")) {
|
||||||
|
level = "notice";
|
||||||
|
} else if (ucMsg.includes("WARN")) {
|
||||||
|
level = "warning";
|
||||||
|
} else if (ucMsg.includes("ERROR")) {
|
||||||
|
level = "error";
|
||||||
|
} else if (ucMsg.includes("CRITICAL")) {
|
||||||
|
level = "critical";
|
||||||
|
} else if (ucMsg.includes("ALERT")) {
|
||||||
|
level = "alert";
|
||||||
|
} else if (ucMsg.includes("EMERGENCY")) {
|
||||||
|
level = "emergency";
|
||||||
|
} else if (ucMsg.includes("SIGINT")) {
|
||||||
|
message = "SIGINT received. Server shutdown.";
|
||||||
|
level = "emergency";
|
||||||
|
} else if (ucMsg.includes("SIGHUP")) {
|
||||||
|
message = "SIGHUP received. Server shutdown.";
|
||||||
|
level = "emergency";
|
||||||
|
} else if (ucMsg.includes("SIGTERM")) {
|
||||||
|
message = "SIGTERM received. Server shutdown.";
|
||||||
|
level = "emergency";
|
||||||
|
} else {
|
||||||
|
level = "info";
|
||||||
|
}
|
||||||
webAppTransport.send({
|
webAppTransport.send({
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
method: "notifications/stderr",
|
method: "notifications/message",
|
||||||
params: {
|
params: {
|
||||||
content: chunk.toString(),
|
level,
|
||||||
|
logger: "stdio",
|
||||||
|
data: {
|
||||||
|
message,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue