Merge branch 'main' into main

This commit is contained in:
Nathan Heaps 2025-08-22 09:55:42 -04:00 • committed by GitHub
commit d758e8be93
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 819 additions and 601 deletions

View file

@ -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}

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

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

View file

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

View file

@ -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 =

View file

@ -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>;

View 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",
);
});
});
});

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

View file

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