Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Configuration" support in UI for configuring the request timeout (and more things in the future) #204

Merged
merged 12 commits into from
Mar 29, 2025
Merged
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Thanks for your interest in contributing! This guide explains how to get involve
## Development Process & Pull Requests

1. Create a new branch for your changes
2. Make your changes following existing code style and conventions
3. Test changes locally
2. Make your changes following existing code style and conventions. You can run `npm run prettier-check` and `npm run prettier-fix` as applicable.
3. Test changes locally by running `npm test`
4. Update documentation as needed
5. Use clear commit messages explaining your changes
6. Verify all changes work as expected
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ The inspector supports bearer token authentication for SSE connections. Enter yo

The MCP Inspector includes a proxy server that can run and communicate with local MCP processes. The proxy server should not be exposed to untrusted networks as it has permissions to spawn local processes and can connect to any specified MCP server.

### Configuration

The MCP Inspector supports the following configuration settings. To change them click on the `Configuration` button in the MCP Inspector UI :

| Name | Purpose | Default Value |
| -------------------------- | ----------------------------------------------------------------------------------------- | ------------- |
| MCP_SERVER_REQUEST_TIMEOUT | Maximum time in milliseconds to wait for a response from the MCP server before timing out | 10000 |

### From this repository

If you're working on the inspector itself:
Expand Down
15 changes: 15 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";

const params = new URLSearchParams(window.location.search);
const PROXY_PORT = params.get("proxyPort") ?? "3000";
const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";

const App = () => {
// Handle OAuth callback route
Expand Down Expand Up @@ -89,6 +92,11 @@ const App = () => {
>([]);
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});

const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
return savedConfig ? JSON.parse(savedConfig) : DEFAULT_INSPECTOR_CONFIG;
});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
Expand Down Expand Up @@ -145,6 +153,7 @@ const App = () => {
env,
bearerToken,
proxyServerUrl: PROXY_SERVER_URL,
requestTimeout: config.MCP_SERVER_REQUEST_TIMEOUT.value as number,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
Expand Down Expand Up @@ -183,6 +192,10 @@ const App = () => {
localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]);

useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);

// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
const serverUrl = params.get("serverUrl");
Expand Down Expand Up @@ -440,6 +453,8 @@ const App = () => {
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
onConnect={connectMcpServer}
Expand Down
89 changes: 89 additions & 0 deletions client/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Github,
Eye,
EyeOff,
Settings,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -23,6 +24,7 @@ import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";

import useTheme from "../lib/useTheme";
import { version } from "../../../package.json";
Expand All @@ -46,6 +48,8 @@ interface SidebarProps {
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
}

const Sidebar = ({
Expand All @@ -67,10 +71,13 @@ const Sidebar = ({
logLevel,
sendLogLevelRequest,
loggingSupported,
config,
setConfig,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());

return (
Expand Down Expand Up @@ -284,6 +291,88 @@ const Sidebar = ({
</div>
)}

{/* Configuration */}
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowConfig(!showConfig)}
className="flex items-center w-full"
>
{showConfig ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
<Settings className="w-4 h-4 mr-2" />
Configuration
</Button>
{showConfig && (
<div className="space-y-2">
{Object.entries(config).map(([key, configItem]) => {
const configKey = key as keyof InspectorConfig;
return (
<div key={key} className="space-y-2">
<label className="text-sm font-medium">
{configItem.description}
</label>
{typeof configItem.value === "number" ? (
<Input
type="number"
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: Number(e.target.value),
};
setConfig(newConfig);
}}
className="font-mono"
/>
) : typeof configItem.value === "boolean" ? (
<Select
data-testid={`${configKey}-select`}
value={configItem.value.toString()}
onValueChange={(val) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: val === "true",
};
setConfig(newConfig);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
) : (
<Input
data-testid={`${configKey}-input`}
value={configItem.value}
onChange={(e) => {
const newConfig = { ...config };
newConfig[configKey] = {
...configItem,
value: e.target.value,
};
setConfig(newConfig);
}}
className="font-mono"
/>
)}
</div>
);
})}
</div>
)}
</div>

<div className="space-y-2">
<Button className="w-full" onClick={onConnect}>
<Play className="w-4 h-4 mr-2" />
Expand Down
91 changes: 91 additions & 0 deletions client/src/components/__tests__/Sidebar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
import { DEFAULT_INSPECTOR_CONFIG } from "../../lib/constants";
import { InspectorConfig } from "../../lib/configurationTypes";

// Mock theme hook
jest.mock("../../lib/useTheme", () => ({
Expand Down Expand Up @@ -28,6 +30,8 @@ describe("Sidebar Environment Variables", () => {
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};

const renderSidebar = (props = {}) => {
Expand Down Expand Up @@ -304,4 +308,91 @@ describe("Sidebar Environment Variables", () => {
expect(setEnv).toHaveBeenCalledWith({ [longKey]: "test_value" });
});
});

describe("Configuration Operations", () => {
const openConfigSection = () => {
const button = screen.getByText("Configuration");
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({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 5000,
},
});
});

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({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 0,
},
});
});

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(
<Sidebar
{...defaultProps}
config={updatedConfig}
setConfig={setConfig}
/>,
);

// 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({
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 3000,
},
});
});
});
});
18 changes: 18 additions & 0 deletions client/src/lib/configurationTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type ConfigItem = {
description: string;
value: string | number | boolean;
};

/**
* Configuration interface for the MCP Inspector, including settings for the MCP Client,
* Proxy Server, and Inspector UI/UX.
*
* Note: Configuration related to which MCP Server to use or any other MCP Server
* specific settings are outside the scope of this interface as of now.
*/
export type InspectorConfig = {
/**
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;
};
9 changes: 9 additions & 0 deletions client/src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { InspectorConfig } from "./configurationTypes";

// OAuth-related session storage keys
export const SESSION_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
} as const;

export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
},
} as const;
8 changes: 3 additions & 5 deletions client/src/lib/hooks/useConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import packageJson from "../../../package.json";

const params = new URLSearchParams(window.location.search);
const DEFAULT_REQUEST_TIMEOUT_MSEC =
parseInt(params.get("timeout") ?? "") || 10000;

interface UseConnectionOptions {
transportType: "stdio" | "sse";
command: string;
Expand All @@ -48,7 +44,9 @@ interface UseConnectionOptions {
requestTimeout?: number;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onPendingRequest?: (request: any, resolve: any, reject: any) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRoots?: () => any[];
}

Expand All @@ -66,7 +64,7 @@ export function useConnection({
env,
proxyServerUrl,
bearerToken,
requestTimeout = DEFAULT_REQUEST_TIMEOUT_MSEC,
requestTimeout,
onNotification,
onStdErrNotification,
onPendingRequest,
Expand Down
Binary file modified mcp-inspector.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"scripts": {
"dev": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev\"",
"dev:windows": "concurrently \"cd client && npm run dev\" \"cd server && npm run dev:windows",
"test": "npm run prettier-check && cd client && npm test",
"build-server": "cd server && npm run build",
"build-client": "cd client && npm run build",
"build": "npm run build-server && npm run build-client",
Expand All @@ -31,6 +32,7 @@
"start": "node ./bin/cli.js",
"prepare": "npm run build",
"prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .",
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
Expand Down