Compare commits

..

1 Commits

Author SHA1 Message Date
Cliff Hall
4fc742b01d Revert "Bump version to 0.11.0" 2025-04-30 12:38:15 -04:00
34 changed files with 573 additions and 5786 deletions

66
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
on:
push:
branches:
- main
pull_request:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: npx prettier --check .
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
# Working around https://github.com/npm/cli/issues/4828
# - run: npm ci
- run: npm install --no-package-lock
- name: Check linting
working-directory: ./client
run: npm run lint
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
registry-url: "https://registry.npmjs.org"
# Working around https://github.com/npm/cli/issues/4828
# - run: npm ci
- run: npm install --no-package-lock
- run: npm run build
# TODO: Add --provenance once the repo is public
- run: npm run publish-all
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,39 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm install
RUN cd client && \
npm install && \
npm run build
RUN cd server && \
npm install && \
npm run build
RUN cd cli && \
npm install && \
npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/client/bin ./client/bin
COPY --from=builder /app/client/dist ./client/dist
COPY --from=builder /app/server/build ./server/build
COPY --from=builder /app/cli/build ./cli/build
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/sample-config.json ./
RUN npm ci --omit=dev --ignore-scripts
ENV NODE_ENV=production
CMD ["node", "client/bin/start.js"]

105
README.md
View File

@@ -1,9 +1,3 @@
# Original Repo: [https://github.com/modelcontextprotocol/inspector](https://github.com/modelcontextprotocol/inspector)
## Modification Log
- Containerize
- Support custom headers for sse transport
# MCP Inspector
The MCP inspector is a developer tool for testing and debugging MCP servers.
@@ -48,74 +42,6 @@ CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build
For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging).
### Servers File Export
The MCP Inspector provides convenient buttons to export server launch configurations for use in clients such as Cursor, Claude Code, or the Inspector's CLI. The file is usually called `mcp.json`.
- **Server Entry** - Copies a single server configuration entry to your clipboard. This can be added to your `mcp.json` file inside the `mcpServers` object with your preferred server name.
**STDIO transport example:**
```json
{
"command": "node",
"args": ["build/index.js", "--debug"],
"env": {
"API_KEY": "your-api-key",
"DEBUG": "true"
}
}
```
**SSE transport example:**
```json
{
"type": "sse",
"url": "http://localhost:3000/events",
"note": "For SSE connections, add this URL directly in Client"
}
```
- **Servers File** - Copies a complete MCP configuration file structure to your clipboard, with your current server configuration added as `default-server`. This can be saved directly as `mcp.json`.
**STDIO transport example:**
```json
{
"mcpServers": {
"default-server": {
"command": "node",
"args": ["build/index.js", "--debug"],
"env": {
"API_KEY": "your-api-key",
"DEBUG": "true"
}
}
}
}
```
**SSE transport example:**
```json
{
"mcpServers": {
"default-server": {
"type": "sse",
"url": "http://localhost:3000/events",
"note": "For SSE connections, add this URL directly in Client"
}
}
}
```
These buttons appear in the Inspector UI after you've configured your server settings, making it easy to save and reuse your configurations.
For SSE transport connections, the Inspector provides similar functionality for both buttons. The "Server Entry" button copies the SSE URL configuration that can be added to your existing configuration file, while the "Servers File" button creates a complete configuration file containing the SSE URL for direct use in clients.
You can paste the Server Entry into your existing `mcp.json` file under your chosen server name, or use the complete Servers File payload to create a new configuration file.
### Authentication
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
@@ -128,13 +54,12 @@ The MCP Inspector includes a proxy server that can run and communicate with loca
The MCP Inspector supports the following configuration settings. To change them, click on the `Configuration` button in the MCP Inspector UI:
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
| `MCP_AUTO_OPEN_ENABLED` | Enable automatic browser opening when inspector starts. Only as environment var, not configurable in browser. | true |
| Setting | Description | Default |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------- |
| `MCP_SERVER_REQUEST_TIMEOUT` | Timeout for requests to the MCP server (ms) | 10000 |
| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | Reset timeout on progress notifications | true |
| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications) | 60000 |
| `MCP_PROXY_FULL_ADDRESS` | Set this if you are running the MCP Inspector Proxy on a non-default address. Example: http://10.1.1.22:5577 | "" |
These settings can be adjusted in real-time through the UI and will persist across sessions.
@@ -168,24 +93,6 @@ Example server configuration file:
}
```
> **Tip:** You can easily generate this configuration format using the **Server Entry** and **Servers File** buttons in the Inspector UI, as described in the Servers File Export section above.
You can also set the initial `transport` type, `serverUrl`, `serverCommand`, and `serverArgs` via query params, for example:
```
http://localhost:6274/?transport=sse&serverUrl=http://localhost:8787/sse
http://localhost:6274/?transport=streamable-http&serverUrl=http://localhost:8787/mcp
http://localhost:6274/?transport=stdio&serverCommand=npx&serverArgs=arg1%20arg2
```
You can also set initial config settings via query params, for example:
```
http://localhost:6274/?MCP_SERVER_REQUEST_TIMEOUT=10000&MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS=false&MCP_PROXY_FULL_ADDRESS=http://10.1.1.22:5577
```
Note that if both the query param and the corresponding localStorage item are set, the query param will take precedence.
### From this repository
If you're working on the inspector itself:

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.12.0",
"version": "0.10.2",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -21,7 +21,7 @@
},
"devDependencies": {},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.10.0",
"commander": "^13.1.0",
"spawn-rx": "^5.1.2"
}

View File

@@ -9,34 +9,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const distPath = join(__dirname, "../dist");
const server = http.createServer((request, response) => {
const handlerOptions = {
return handler(request, response, {
public: distPath,
rewrites: [{ source: "/**", destination: "/index.html" }],
headers: [
{
// Ensure index.html is never cached
source: "index.html",
headers: [
{
key: "Cache-Control",
value: "no-cache, no-store, max-age=0",
},
],
},
{
// Allow long-term caching for hashed assets
source: "assets/**",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
],
};
return handler(request, response, handlerOptions);
});
});
const port = process.env.PORT || 6274;

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env node
import open from "open";
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
@@ -100,9 +99,6 @@ async function main() {
if (serverOk) {
try {
if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") {
open(`http://127.0.0.1:${CLIENT_PORT}`);
}
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.12.0",
"version": "0.10.2",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.10.0",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.3",
"@radix-ui/react-icons": "^1.3.0",
@@ -51,7 +51,7 @@
"devDependencies": {
"@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^14.2.1",
"@testing-library/react": "^16.2.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5",
"@types/prismjs": "^1.26.5",

View File

@@ -17,9 +17,6 @@ import {
Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js";
import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState } from "./lib/auth-types";
import React, {
Suspense,
useCallback,
@@ -31,21 +28,18 @@ import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Bell,
Files,
FolderTree,
Hammer,
Hash,
Key,
MessageSquare,
} from "lucide-react";
import { z } from "zod";
import "./App.css";
import AuthDebugger from "./components/AuthDebugger";
import ConsoleTab from "./components/ConsoleTab";
import HistoryAndNotifications from "./components/History";
import PingTab from "./components/PingTab";
@@ -55,15 +49,9 @@ import RootsTab from "./components/RootsTab";
import SamplingTab, { PendingRequest } from "./components/SamplingTab";
import Sidebar from "./components/Sidebar";
import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import {
getMCPProxyAddress,
getInitialSseUrl,
getInitialTransportType,
getInitialCommand,
getInitialArgs,
initializeInspectorConfig,
} from "./utils/configUtils";
import { getMCPProxyAddress } from "./utils/configUtils";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
@@ -83,13 +71,26 @@ const App = () => {
prompts: null,
tools: null,
});
const [command, setCommand] = useState<string>(getInitialCommand);
const [args, setArgs] = useState<string>(getInitialArgs);
const [command, setCommand] = useState<string>(() => {
return localStorage.getItem("lastCommand") || "mcp-server-everything";
});
const [args, setArgs] = useState<string>(() => {
return localStorage.getItem("lastArgs") || "";
});
const [sseUrl, setSseUrl] = useState<string>(getInitialSseUrl);
const [sseUrl, setSseUrl] = useState<string>(() => {
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
});
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(getInitialTransportType);
>(() => {
return (
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
});
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState<
@@ -98,9 +99,27 @@ const App = () => {
const [roots, setRoots] = useState<Root[]>([]);
const [env, setEnv] = useState<Record<string, string>>({});
const [config, setConfig] = useState<InspectorConfig>(() =>
initializeInspectorConfig(CONFIG_LOCAL_STORAGE_KEY),
);
const [config, setConfig] = useState<InspectorConfig>(() => {
const savedConfig = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY);
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// update description of keys to match the new description (in case of any updates to the default config description)
Object.entries(mergedConfig).forEach(([key, value]) => {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
});
return mergedConfig;
}
return DEFAULT_INSPECTOR_CONFIG;
});
const [bearerToken, setBearerToken] = useState<string>(() => {
return localStorage.getItem("lastBearerToken") || "";
});
@@ -109,11 +128,6 @@ const App = () => {
return localStorage.getItem("lastHeaderName") || "";
});
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const saved = localStorage.getItem("lastCustomHeaders");
return saved ? JSON.parse(saved) : [];
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@@ -122,27 +136,6 @@ const App = () => {
}
>
>([]);
const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false);
// Auth debugger state
const [authState, setAuthState] = useState<AuthDebuggerState>({
isInitiatingAuth: false,
oauthTokens: null,
loading: true,
oauthStep: "metadata_discovery",
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
});
// Helper function to update specific auth state properties
const updateAuthState = (updates: Partial<AuthDebuggerState>) => {
setAuthState((prev) => ({ ...prev, ...updates }));
};
const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]);
@@ -188,7 +181,6 @@ const App = () => {
env,
bearerToken,
headerName,
customHeaders,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -232,10 +224,6 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName);
}, [headerName]);
useEffect(() => {
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
}, [customHeaders]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
@@ -245,64 +233,11 @@ const App = () => {
(serverUrl: string) => {
setSseUrl(serverUrl);
setTransportType("sse");
setIsAuthDebuggerVisible(false);
void connectMcpServer();
},
[connectMcpServer],
);
// Update OAuth debug state during debug callback
const onOAuthDebugConnect = useCallback(
({
authorizationCode,
errorMsg,
}: {
authorizationCode?: string;
errorMsg?: string;
}) => {
setIsAuthDebuggerVisible(true);
if (authorizationCode) {
updateAuthState({
authorizationCode,
oauthStep: "token_request",
});
}
if (errorMsg) {
updateAuthState({
latestError: new Error(errorMsg),
});
}
},
[],
);
// Load OAuth tokens when sseUrl changes
useEffect(() => {
const loadOAuthTokens = async () => {
try {
if (sseUrl) {
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl);
const tokens = sessionStorage.getItem(key);
if (tokens) {
const parsedTokens = await OAuthTokensSchema.parseAsync(
JSON.parse(tokens),
);
updateAuthState({
oauthTokens: parsedTokens,
oauthStep: "complete",
});
}
}
} catch (error) {
console.error("Error loading OAuth tokens:", error);
} finally {
updateAuthState({ loading: false });
}
};
loadOAuthTokens();
}, [sseUrl]);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
.then((response) => response.json())
@@ -536,19 +471,6 @@ const App = () => {
setStdErrNotifications([]);
};
// Helper component for rendering the AuthDebugger
const AuthDebuggerWrapper = () => (
<TabsContent value="auth">
<AuthDebugger
serverUrl={sseUrl}
onBack={() => setIsAuthDebuggerVisible(false)}
authState={authState}
updateAuthState={updateAuthState}
/>
</TabsContent>
);
// Helper function to render OAuth callback components
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
@@ -560,17 +482,6 @@ const App = () => {
);
}
if (window.location.pathname === "/oauth/callback/debug") {
const OAuthDebugCallback = React.lazy(
() => import("./components/OAuthDebugCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthDebugCallback onConnect={onOAuthDebugConnect} />
</Suspense>
);
}
return (
<div className="flex h-screen bg-background">
<Sidebar
@@ -591,8 +502,6 @@ const App = () => {
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
@@ -660,34 +569,17 @@ const App = () => {
<FolderTree className="w-4 h-4 mr-2" />
Roots
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500">
The connected server does not support any MCP capabilities
</p>
</div>
) : (
<>
<ResourcesTab
@@ -809,36 +701,15 @@ const App = () => {
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
</>
)}
</div>
</Tabs>
) : isAuthDebuggerVisible ? (
<Tabs
defaultValue={"auth"}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<AuthDebuggerWrapper />
</Tabs>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="flex items-center justify-center h-full">
<p className="text-lg text-gray-500">
Connect to an MCP server to start inspecting
</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Need to configure authentication?
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAuthDebuggerVisible(true)}
>
Open Auth Settings
</Button>
</div>
</div>
)}
</div>

View File

@@ -1,260 +0,0 @@
import { useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { DebugInspectorOAuthClientProvider } from "../lib/auth";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { AlertCircle } from "lucide-react";
import { AuthDebuggerState } from "../lib/auth-types";
import { OAuthFlowProgress } from "./OAuthFlowProgress";
import { OAuthStateMachine } from "../lib/oauth-state-machine";
export interface AuthDebuggerProps {
serverUrl: string;
onBack: () => void;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
}
interface StatusMessageProps {
message: { type: "error" | "success" | "info"; message: string };
}
const StatusMessage = ({ message }: StatusMessageProps) => {
let bgColor: string;
let textColor: string;
let borderColor: string;
switch (message.type) {
case "error":
bgColor = "bg-red-50";
textColor = "text-red-700";
borderColor = "border-red-200";
break;
case "success":
bgColor = "bg-green-50";
textColor = "text-green-700";
borderColor = "border-green-200";
break;
case "info":
default:
bgColor = "bg-blue-50";
textColor = "text-blue-700";
borderColor = "border-blue-200";
break;
}
return (
<div
className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`}
>
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<p className="text-sm">{message.message}</p>
</div>
</div>
);
};
const AuthDebugger = ({
serverUrl: serverUrl,
onBack,
authState,
updateAuthState,
}: AuthDebuggerProps) => {
const startOAuthFlow = useCallback(() => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({
oauthStep: "metadata_discovery",
authorizationUrl: null,
statusMessage: null,
latestError: null,
});
}, [serverUrl, updateAuthState]);
const stateMachine = useMemo(
() => new OAuthStateMachine(serverUrl, updateAuthState),
[serverUrl, updateAuthState],
);
const proceedToNextStep = useCallback(async () => {
if (!serverUrl) return;
try {
updateAuthState({
isInitiatingAuth: true,
statusMessage: null,
latestError: null,
});
await stateMachine.executeStep(authState);
} catch (error) {
console.error("OAuth flow error:", error);
updateAuthState({
latestError: error instanceof Error ? error : new Error(String(error)),
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, authState, updateAuthState, stateMachine]);
const handleQuickOAuth = useCallback(async () => {
if (!serverUrl) {
updateAuthState({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
return;
}
updateAuthState({ isInitiatingAuth: true, statusMessage: null });
try {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
await auth(serverAuthProvider, { serverUrl: serverUrl });
updateAuthState({
statusMessage: {
type: "info",
message: "Starting OAuth authentication process...",
},
});
} catch (error) {
console.error("OAuth initialization error:", error);
updateAuthState({
statusMessage: {
type: "error",
message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
},
});
} finally {
updateAuthState({ isInitiatingAuth: false });
}
}, [serverUrl, updateAuthState]);
const handleClearOAuth = useCallback(() => {
if (serverUrl) {
const serverAuthProvider = new DebugInspectorOAuthClientProvider(
serverUrl,
);
serverAuthProvider.clear();
updateAuthState({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
authorizationCode: "",
validationError: null,
oauthMetadata: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Clear success message after 3 seconds
setTimeout(() => {
updateAuthState({ statusMessage: null });
}, 3000);
}
}, [serverUrl, updateAuthState]);
return (
<div className="w-full p-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold">Authentication Settings</h2>
<Button variant="outline" onClick={onBack}>
Back to Connect
</Button>
</div>
<div className="w-full space-y-6">
<div className="flex flex-col gap-6">
<div className="grid w-full gap-2">
<p className="text-muted-foreground mb-4">
Configure authentication settings for your MCP server connection.
</p>
<div className="rounded-md border p-6 space-y-6">
<h3 className="text-lg font-medium">OAuth Authentication</h3>
<p className="text-sm text-muted-foreground mb-2">
Use OAuth to securely authenticate with the MCP server.
</p>
{authState.statusMessage && (
<StatusMessage message={authState.statusMessage} />
)}
{authState.loading ? (
<p>Loading authentication status...</p>
) : (
<div className="space-y-4">
{authState.oauthTokens && (
<div className="space-y-2">
<p className="text-sm font-medium">Access Token:</p>
<div className="bg-muted p-2 rounded-md text-xs overflow-x-auto">
{authState.oauthTokens.access_token.substring(0, 25)}...
</div>
</div>
)}
<div className="flex gap-4">
<Button
variant="outline"
onClick={startOAuthFlow}
disabled={authState.isInitiatingAuth}
>
{authState.oauthTokens
? "Guided Token Refresh"
: "Guided OAuth Flow"}
</Button>
<Button
onClick={handleQuickOAuth}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth
? "Initiating..."
: authState.oauthTokens
? "Quick Refresh"
: "Quick OAuth Flow"}
</Button>
<Button variant="outline" onClick={handleClearOAuth}>
Clear OAuth State
</Button>
</div>
<p className="text-xs text-muted-foreground">
Choose "Guided" for step-by-step instructions or "Quick" for
the standard automatic flow.
</p>
</div>
)}
</div>
<OAuthFlowProgress
serverUrl={serverUrl}
authState={authState}
updateAuthState={updateAuthState}
proceedToNextStep={proceedToNextStep}
/>
</div>
</div>
</div>
</div>
);
};
export default AuthDebugger;

View File

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

View File

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

View File

@@ -1,92 +0,0 @@
import { useEffect } from "react";
import { SESSION_KEYS } from "../lib/constants";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
interface OAuthCallbackProps {
onConnect: ({
authorizationCode,
errorMsg,
}: {
authorizationCode?: string;
errorMsg?: string;
}) => void;
}
const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => {
useEffect(() => {
let isProcessed = false;
const handleCallback = async () => {
// Skip if we've already processed this callback
if (isProcessed) {
return;
}
isProcessed = true;
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
const errorMsg = generateOAuthErrorDescription(params);
onConnect({ errorMsg });
return;
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
// ServerURL isn't set, this can happen if we've opened the
// authentication request in a new tab, so we don't have the same
// session storage
if (!serverUrl) {
// If there's no server URL, we're likely in a new tab
// Just display the code for manual copying
return;
}
if (!params.code) {
onConnect({ errorMsg: "Missing authorization code" });
return;
}
// Instead of storing in sessionStorage, pass the code directly
// to the auth state manager through onConnect
onConnect({ authorizationCode: params.code });
};
handleCallback().finally(() => {
// Only redirect if we have the URL set, otherwise assume this was
// in a new tab
if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) {
window.history.replaceState({}, document.title, "/");
}
});
return () => {
isProcessed = true;
};
}, [onConnect]);
const callbackParams = parseOAuthCallbackParams(window.location.search);
return (
<div className="flex items-center justify-center h-screen">
<div className="mt-4 p-4 bg-secondary rounded-md max-w-md">
<p className="mb-2 text-sm">
Please copy this authorization code and return to the Auth Debugger:
</p>
<code className="block p-2 bg-muted rounded-sm overflow-x-auto text-xs">
{callbackParams.successful && "code" in callbackParams
? callbackParams.code
: `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}
</code>
<p className="mt-4 text-xs text-muted-foreground">
Close this tab and paste the code in the OAuth flow to complete
authentication.
</p>
</div>
</div>
);
};
export default OAuthDebugCallback;

View File

@@ -1,259 +0,0 @@
import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types";
import { CheckCircle2, Circle, ExternalLink } from "lucide-react";
import { Button } from "./ui/button";
import { DebugInspectorOAuthClientProvider } from "@/lib/auth";
interface OAuthStepProps {
label: string;
isComplete: boolean;
isCurrent: boolean;
error?: Error | null;
children?: React.ReactNode;
}
const OAuthStepDetails = ({
label,
isComplete,
isCurrent,
error,
children,
}: OAuthStepProps) => {
return (
<div>
<div
className={`flex items-center p-2 rounded-md ${isCurrent ? "bg-accent" : ""}`}
>
{isComplete ? (
<CheckCircle2 className="h-5 w-5 text-green-500 mr-2" />
) : (
<Circle className="h-5 w-5 text-muted-foreground mr-2" />
)}
<span className={`${isCurrent ? "font-medium" : ""}`}>{label}</span>
</div>
{/* Show children if current step or complete and children exist */}
{(isCurrent || isComplete) && children && (
<div className="ml-7 mt-1">{children}</div>
)}
{/* Display error if current step and an error exists */}
{isCurrent && error && (
<div className="ml-7 mt-2 p-3 border border-red-300 bg-red-50 rounded-md">
<p className="text-sm font-medium text-red-700">Error:</p>
<p className="text-xs text-red-600 mt-1">{error.message}</p>
</div>
)}
</div>
);
};
interface OAuthFlowProgressProps {
serverUrl: string;
authState: AuthDebuggerState;
updateAuthState: (updates: Partial<AuthDebuggerState>) => void;
proceedToNextStep: () => Promise<void>;
}
export const OAuthFlowProgress = ({
serverUrl,
authState,
updateAuthState,
proceedToNextStep,
}: OAuthFlowProgressProps) => {
const provider = new DebugInspectorOAuthClientProvider(serverUrl);
const steps: Array<OAuthStep> = [
"metadata_discovery",
"client_registration",
"authorization_redirect",
"authorization_code",
"token_request",
"complete",
];
const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep);
// Helper to get step props
const getStepProps = (stepName: OAuthStep) => ({
isComplete:
currentStepIdx > steps.indexOf(stepName) ||
currentStepIdx === steps.length - 1, // last step is "complete"
isCurrent: authState.oauthStep === stepName,
error: authState.oauthStep === stepName ? authState.latestError : null,
});
return (
<div className="rounded-md border p-6 space-y-4 mt-4">
<h3 className="text-lg font-medium">OAuth Flow Progress</h3>
<p className="text-sm text-muted-foreground">
Follow these steps to complete OAuth authentication with the server.
</p>
<div className="space-y-3">
<OAuthStepDetails
label="Metadata Discovery"
{...getStepProps("metadata_discovery")}
>
{provider.getServerMetadata() && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Retrieved OAuth Metadata from {serverUrl}
/.well-known/oauth-authorization-server
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(provider.getServerMetadata(), null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Client Registration"
{...getStepProps("client_registration")}
>
{authState.oauthClientInfo && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Registered Client Information
</summary>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthClientInfo, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Preparing Authorization"
{...getStepProps("authorization_redirect")}
>
{authState.authorizationUrl && (
<div className="mt-2 p-3 border rounded-md bg-muted">
<p className="font-medium mb-2 text-sm">Authorization URL:</p>
<div className="flex items-center gap-2">
<p className="text-xs break-all">
{authState.authorizationUrl}
</p>
<a
href={authState.authorizationUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-blue-500 hover:text-blue-700"
aria-label="Open authorization URL in new tab"
title="Open authorization URL"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
<p className="text-xs text-muted-foreground mt-2">
Click the link to authorize in your browser. After
authorization, you'll be redirected back to continue the flow.
</p>
</div>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Request Authorization and acquire authorization code"
{...getStepProps("authorization_code")}
>
<div className="mt-3">
<label
htmlFor="authCode"
className="block text-sm font-medium mb-1"
>
Authorization Code
</label>
<div className="flex gap-2">
<input
id="authCode"
value={authState.authorizationCode}
onChange={(e) => {
updateAuthState({
authorizationCode: e.target.value,
validationError: null,
});
}}
placeholder="Enter the code from the authorization server"
className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
authState.validationError ? "border-red-500" : "border-input"
}`}
/>
</div>
{authState.validationError && (
<p className="text-xs text-red-600 mt-1">
{authState.validationError}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Once you've completed authorization in the link, paste the code
here.
</p>
</div>
</OAuthStepDetails>
<OAuthStepDetails
label="Token Request"
{...getStepProps("token_request")}
>
{authState.oauthMetadata && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Token Request Details
</summary>
<div className="mt-2 p-2 bg-muted rounded-md">
<p className="font-medium">Token Endpoint:</p>
<code className="block mt-1 text-xs overflow-x-auto">
{authState.oauthMetadata.token_endpoint}
</code>
</div>
</details>
)}
</OAuthStepDetails>
<OAuthStepDetails
label="Authentication Complete"
{...getStepProps("complete")}
>
{authState.oauthTokens && (
<details className="text-xs mt-2">
<summary className="cursor-pointer text-muted-foreground font-medium">
Access Tokens
</summary>
<p className="mt-1 text-sm">
Authentication successful! You can now use the authenticated
connection. These tokens will be used automatically for server
requests.
</p>
<pre className="mt-2 p-2 bg-muted rounded-md overflow-auto max-h-[300px]">
{JSON.stringify(authState.oauthTokens, null, 2)}
</pre>
</details>
)}
</OAuthStepDetails>
</div>
<div className="flex gap-3 mt-4">
{authState.oauthStep !== "complete" && (
<>
<Button
onClick={proceedToNextStep}
disabled={authState.isInitiatingAuth}
>
{authState.isInitiatingAuth ? "Processing..." : "Continue"}
</Button>
</>
)}
{authState.oauthStep === "authorization_redirect" &&
authState.authorizationUrl && (
<Button
variant="outline"
onClick={() => window.open(authState.authorizationUrl!, "_blank")}
>
Open in New Tab
</Button>
)}
</div>
</div>
);
};

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState } from "react";
import {
Play,
ChevronDown,
@@ -12,8 +12,6 @@ import {
Settings,
HelpCircle,
RefreshCwOff,
Copy,
CheckCheck,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -31,17 +29,15 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { InspectorConfig } from "@/lib/configurationTypes";
import { ConnectionStatus } from "@/lib/constants";
import useTheme from "../lib/hooks/useTheme";
import useTheme from "../lib/useTheme";
import { version } from "../../../package.json";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useToast } from "../lib/hooks/useToast";
export interface SidebarProps {
interface SidebarProps {
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
@@ -57,8 +53,6 @@ export interface SidebarProps {
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
customHeaders: [string, string][];
setCustomHeaders: (headers: [string, string][]) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
@@ -86,8 +80,6 @@ const Sidebar = ({
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
onConnect,
onDisconnect,
stdErrNotifications,
@@ -103,137 +95,6 @@ const Sidebar = ({
const [showBearerToken, setShowBearerToken] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
const [showCustomHeaders, setShowCustomHeaders] = useState(false);
// Reusable error reporter for copy actions
const reportError = useCallback(
(error: unknown) => {
toast({
title: "Error",
description: `Failed to copy config: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
},
[toast],
);
// Shared utility function to generate server config
const generateServerConfig = useCallback(() => {
if (transportType === "stdio") {
return {
command,
args: args.trim() ? args.split(/\s+/) : [],
env: { ...env },
};
}
if (transportType === "sse") {
return {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
};
}
if (transportType === "streamable-http") {
return {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
};
}
return {};
}, [transportType, command, args, env, sseUrl]);
// Memoized config entry generator
const generateMCPServerEntry = useCallback(() => {
return JSON.stringify(generateServerConfig(), null, 4);
}, [generateServerConfig]);
// Memoized config file generator
const generateMCPServerFile = useCallback(() => {
return JSON.stringify(
{
mcpServers: {
"default-server": generateServerConfig(),
},
},
null,
4,
);
}, [generateServerConfig]);
// Memoized copy handlers
const handleCopyServerEntry = useCallback(() => {
try {
const configJson = generateMCPServerEntry();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerEntry(true);
toast({
title: "Config entry copied",
description:
transportType === "stdio"
? "Server configuration has been copied to clipboard. Add this to your mcp.json inside the 'mcpServers' object with your preferred server name."
: "SSE URL has been copied. Use this URL in Cursor directly.",
});
setTimeout(() => {
setCopiedServerEntry(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerEntry, transportType, toast, reportError]);
const handleCopyServerFile = useCallback(() => {
try {
const configJson = generateMCPServerFile();
navigator.clipboard
.writeText(configJson)
.then(() => {
setCopiedServerFile(true);
toast({
title: "Servers file copied",
description:
"Servers configuration has been copied to clipboard. Add this to your mcp.json file. Current testing server will be added as 'default-server'",
});
setTimeout(() => {
setCopiedServerFile(false);
}, 2000);
})
.catch((error) => {
reportError(error);
});
} catch (error) {
reportError(error);
}
}, [generateMCPServerFile, toast, reportError]);
const removeCustomHeader = (index: number) => {
const newHeaders = [...customHeaders];
newHeaders.splice(index, 1);
setCustomHeaders(newHeaders);
};
const updateCustomHeader = (index: number, field: 'key' | 'value', value: string) => {
const newArr = [...customHeaders];
const [oldKey, oldValue] = newArr[index];
const newTuple: [string, string] = field === 'key'
? [value, oldValue]
: [oldKey, value];
newArr[index] = newTuple;
setCustomHeaders(newArr);
};
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
@@ -362,64 +223,6 @@ const Sidebar = ({
</div>
</>
)}
{transportType === "sse" && (
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowCustomHeaders(!showCustomHeaders)}
className="flex items-center w-full"
data-testid="custom-headers-button"
aria-expanded={showCustomHeaders}
>
{showCustomHeaders ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Custom Headers
</Button>
{showCustomHeaders && (
<div className="space-y-2">
{customHeaders.map((header, index) => (
<div key={index} className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Header Name"
value={header[0]}
onChange={(e) => updateCustomHeader(index, 'key', e.target.value)}
className="font-mono"
/>
<label className="text-sm font-medium">Header Value</label>
<Input
placeholder="Header Value"
value={header[1]}
onChange={(e) => updateCustomHeader(index, 'value', e.target.value)}
className="font-mono"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeCustomHeader(index)}
className="w-full"
>
Remove Header
</Button>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
setCustomHeaders([ ...customHeaders, ["", ""]]);
}}
>
Add Custom Header
</Button>
</div>
)}
</div>
)}
{transportType === "stdio" && (
<div className="space-y-2">
<Button
@@ -545,46 +348,6 @@ const Sidebar = ({
</div>
)}
{/* Always show both copy buttons for all transport types */}
<div className="grid grid-cols-2 gap-2 mt-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerEntry}
className="w-full"
>
{copiedServerEntry ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Server Entry
</Button>
</TooltipTrigger>
<TooltipContent>Copy Server Entry</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={handleCopyServerFile}
className="w-full"
>
{copiedServerFile ? (
<CheckCheck className="h-4 w-4 mr-2" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Servers File
</Button>
</TooltipTrigger>
<TooltipContent>Copy Servers File</TooltipContent>
</Tooltip>
</div>
{/* Configuration */}
<div className="space-y-2">
<Button
@@ -799,8 +562,6 @@ const Sidebar = ({
</>
)}
</div>
</div>
</div>
<div className="p-4 border-t">

View File

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

View File

@@ -1,382 +0,0 @@
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SESSION_KEYS } from "@/lib/constants";
const mockOAuthTokens = {
access_token: "test_access_token",
token_type: "Bearer",
expires_in: 3600,
refresh_token: "test_refresh_token",
scope: "test_scope",
};
const mockOAuthMetadata = {
issuer: "https://oauth.example.com",
authorization_endpoint: "https://oauth.example.com/authorize",
token_endpoint: "https://oauth.example.com/token",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
};
const mockOAuthClientInfo = {
client_id: "test_client_id",
client_secret: "test_client_secret",
redirect_uris: ["http://localhost:3000/oauth/callback/debug"],
};
// Mock MCP SDK functions - must be before imports
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
discoverOAuthMetadata: jest.fn(),
registerClient: jest.fn(),
startAuthorization: jest.fn(),
exchangeAuthorization: jest.fn(),
}));
// Import the functions to get their types
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
// Type the mocked functions properly
const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction<
typeof discoverOAuthMetadata
>;
const mockRegisterClient = registerClient as jest.MockedFunction<
typeof registerClient
>;
const mockStartAuthorization = startAuthorization as jest.MockedFunction<
typeof startAuthorization
>;
const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction<
typeof exchangeAuthorization
>;
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
Object.defineProperty(window, "sessionStorage", {
value: sessionStorageMock,
});
Object.defineProperty(window, "location", {
value: {
origin: "http://localhost:3000",
},
});
describe("AuthDebugger", () => {
const defaultAuthState = {
isInitiatingAuth: false,
oauthTokens: null,
loading: false,
oauthStep: "metadata_discovery" as const,
oauthMetadata: null,
oauthClientInfo: null,
authorizationUrl: null,
authorizationCode: "",
latestError: null,
statusMessage: null,
validationError: null,
};
const defaultProps = {
serverUrl: "https://example.com",
onBack: jest.fn(),
authState: defaultAuthState,
updateAuthState: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
sessionStorageMock.getItem.mockReturnValue(null);
mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata);
mockRegisterClient.mockResolvedValue(mockOAuthClientInfo);
mockStartAuthorization.mockImplementation(async (_sseUrl, options) => {
const authUrl = new URL("https://oauth.example.com/authorize");
if (options.scope) {
authUrl.searchParams.set("scope", options.scope);
}
return {
authorizationUrl: authUrl,
codeVerifier: "test_verifier",
};
});
mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens);
});
const renderAuthDebugger = (props: Partial<AuthDebuggerProps> = {}) => {
const mergedProps = {
...defaultProps,
...props,
authState: { ...defaultAuthState, ...(props.authState || {}) },
};
return render(
<TooltipProvider>
<AuthDebugger {...mergedProps} />
</TooltipProvider>,
);
};
describe("Initial Rendering", () => {
it("should render the component with correct title", async () => {
await act(async () => {
renderAuthDebugger();
});
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
});
it("should call onBack when Back button is clicked", async () => {
const onBack = jest.fn();
await act(async () => {
renderAuthDebugger({ onBack });
});
fireEvent.click(screen.getByText("Back to Connect"));
expect(onBack).toHaveBeenCalled();
});
});
describe("OAuth Flow", () => {
it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => {
await act(async () => {
renderAuthDebugger();
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument();
});
it("should show error when OAuth flow is started without sseUrl", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({ serverUrl: "", updateAuthState });
});
await act(async () => {
fireEvent.click(screen.getByText("Guided OAuth Flow"));
});
expect(updateAuthState).toHaveBeenCalledWith({
statusMessage: {
type: "error",
message:
"Please enter a server URL in the sidebar before authenticating",
},
});
});
});
describe("Session Storage Integration", () => {
it("should load OAuth tokens from session storage", async () => {
// Mock the specific key for tokens with server URL
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return JSON.stringify(mockOAuthTokens);
}
return null;
});
await act(async () => {
renderAuthDebugger({
authState: {
...defaultAuthState,
oauthTokens: mockOAuthTokens,
},
});
});
await waitFor(() => {
expect(screen.getByText(/Access Token:/)).toBeInTheDocument();
});
});
it("should handle errors loading OAuth tokens from session storage", async () => {
// Mock console to avoid cluttering test output
const originalError = console.error;
console.error = jest.fn();
// Mock getItem to return invalid JSON for tokens
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return "invalid json";
}
return null;
});
await act(async () => {
renderAuthDebugger();
});
// Component should still render despite the error
expect(screen.getByText("Authentication Settings")).toBeInTheDocument();
// Restore console.error
console.error = originalError;
});
});
describe("OAuth State Management", () => {
it("should clear OAuth state when Clear button is clicked", async () => {
const updateAuthState = jest.fn();
// Mock the session storage to return tokens for the specific key
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === "[https://example.com] mcp_tokens") {
return JSON.stringify(mockOAuthTokens);
}
return null;
});
await act(async () => {
renderAuthDebugger({
authState: {
...defaultAuthState,
oauthTokens: mockOAuthTokens,
},
updateAuthState,
});
});
await act(async () => {
fireEvent.click(screen.getByText("Clear OAuth State"));
});
expect(updateAuthState).toHaveBeenCalledWith({
oauthTokens: null,
oauthStep: "metadata_discovery",
latestError: null,
oauthClientInfo: null,
oauthMetadata: null,
authorizationCode: "",
validationError: null,
statusMessage: {
type: "success",
message: "OAuth tokens cleared successfully",
},
});
// Verify session storage was cleared
expect(sessionStorageMock.removeItem).toHaveBeenCalled();
});
});
describe("OAuth Flow Steps", () => {
it("should handle OAuth flow step progression", async () => {
const updateAuthState = jest.fn();
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false, // Changed to false so button is enabled
oauthStep: "metadata_discovery",
},
});
});
// Verify metadata discovery step
expect(screen.getByText("Metadata Discovery")).toBeInTheDocument();
// Click Continue - this should trigger metadata discovery
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith(
"https://example.com",
);
});
// Setup helper for OAuth authorization tests
const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => {
const updateAuthState = jest.fn();
// Mock the session storage to return metadata
sessionStorageMock.getItem.mockImplementation((key) => {
if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) {
return JSON.stringify(metadata);
}
if (
key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}`
) {
return JSON.stringify(mockOAuthClientInfo);
}
return null;
});
await act(async () => {
renderAuthDebugger({
updateAuthState,
authState: {
...defaultAuthState,
isInitiatingAuth: false,
oauthStep: "authorization_redirect",
oauthMetadata: metadata,
oauthClientInfo: mockOAuthClientInfo,
},
});
});
// Click Continue to trigger authorization
await act(async () => {
fireEvent.click(screen.getByText("Continue"));
});
return updateAuthState;
};
it("should include scope in authorization URL when scopes_supported is present", async () => {
const metadataWithScopes = {
...mockOAuthMetadata,
scopes_supported: ["read", "write", "admin"],
};
const updateAuthState =
await setupAuthorizationUrlTest(metadataWithScopes);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.stringContaining("scope="),
}),
);
});
});
it("should not include scope in authorization URL when scopes_supported is not present", async () => {
const updateAuthState =
await setupAuthorizationUrlTest(mockOAuthMetadata);
// Wait for the updateAuthState to be called
await waitFor(() => {
expect(updateAuthState).toHaveBeenCalledWith(
expect.objectContaining({
authorizationUrl: expect.not.stringContaining("scope="),
}),
);
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import Sidebar from "../Sidebar";
@@ -7,60 +7,37 @@ import { InspectorConfig } from "@/lib/configurationTypes";
import { TooltipProvider } from "@/components/ui/tooltip";
// Mock theme hook
jest.mock("../../lib/hooks/useTheme", () => ({
jest.mock("../../lib/useTheme", () => ({
__esModule: true,
default: () => ["light", jest.fn()],
}));
// Mock toast hook
const mockToast = jest.fn();
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
// Mock navigator clipboard
const mockClipboardWrite = jest.fn(() => Promise.resolve());
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockClipboardWrite,
},
});
// Setup fake timers
jest.useFakeTimers();
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
setTransportType: jest.fn(),
command: "",
setCommand: jest.fn(),
args: "",
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
headerName: "",
setHeaderName: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "debug" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: false,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
describe("Sidebar Environment Variables", () => {
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
setTransportType: jest.fn(),
command: "",
setCommand: jest.fn(),
args: "",
setArgs: jest.fn(),
sseUrl: "",
setSseUrl: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
const renderSidebar = (props = {}) => {
return render(
<TooltipProvider>
@@ -76,7 +53,6 @@ describe("Sidebar Environment Variables", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
describe("Basic Operations", () => {
@@ -646,307 +622,4 @@ describe("Sidebar Environment Variables", () => {
);
});
});
describe("Copy Configuration Features", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
});
const getCopyButtons = () => {
return {
serverEntry: screen.getByRole("button", { name: /server entry/i }),
serversFile: screen.getByRole("button", { name: /servers file/i }),
};
};
it("should render both copy buttons for all transport types", () => {
["stdio", "sse", "streamable-http"].forEach((transportType) => {
renderSidebar({ transportType });
// There should be exactly one Server Entry and one Servers File button per render
const serverEntryButtons = screen.getAllByRole("button", {
name: /server entry/i,
});
const serversFileButtons = screen.getAllByRole("button", {
name: /servers file/i,
});
expect(serverEntryButtons).toHaveLength(1);
expect(serversFileButtons).toHaveLength(1);
// Clean up DOM for next iteration
// (Testing Library's render does not auto-unmount in a loop)
document.body.innerHTML = "";
});
});
it("should copy server entry configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: ["--inspect", "server.js"],
env,
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for STDIO transport", async () => {
const command = "node";
const args = "--inspect server.js";
const env = { API_KEY: "test-key", DEBUG: "true" };
renderSidebar({
transportType: "stdio",
command,
args,
env,
});
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
command,
args: ["--inspect", "server.js"],
env,
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for SSE transport", async () => {
const sseUrl = "http://localhost:3000/events";
renderSidebar({ transportType: "sse", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "sse",
url: sseUrl,
note: "For SSE connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy server entry configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should copy servers file configuration to clipboard for streamable-http transport", async () => {
const sseUrl = "http://localhost:3001/sse";
renderSidebar({ transportType: "streamable-http", sseUrl });
await act(async () => {
const { serversFile } = getCopyButtons();
fireEvent.click(serversFile);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
mcpServers: {
"default-server": {
type: "streamable-http",
url: sseUrl,
note: "For Streamable HTTP connections, add this URL directly in Client",
},
},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
it("should handle empty args in STDIO transport", async () => {
const command = "python";
const args = "";
renderSidebar({
transportType: "stdio",
command,
args,
});
await act(async () => {
const { serverEntry } = getCopyButtons();
fireEvent.click(serverEntry);
jest.runAllTimers();
});
expect(mockClipboardWrite).toHaveBeenCalledTimes(1);
const expectedConfig = JSON.stringify(
{
command,
args: [],
env: {},
},
null,
4,
);
expect(mockClipboardWrite).toHaveBeenCalledWith(expectedConfig);
});
});
});
describe("Sidebar", () => {
it("renders", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("MCP Inspector")).toBeInTheDocument();
});
it("shows connect button when disconnected", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("Connect")).toBeInTheDocument();
});
it("shows disconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Disconnect")).toBeInTheDocument();
});
it("shows reconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="sse"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Reconnect")).toBeInTheDocument();
});
it("shows restart button when connected with stdio transport", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="stdio"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Restart")).toBeInTheDocument();
});
it("shows environment variables section when stdio transport is selected", () => {
render(
<Sidebar
{...defaultProps}
env={{ NEW_KEY: "new_value" }}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const envButton = screen.getByText("Environment Variables");
expect(envButton).toBeInTheDocument();
});
it("shows configuration section", () => {
render(
<Sidebar
{...defaultProps}
config={DEFAULT_INSPECTOR_CONFIG}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const configButton = screen.getByText("Configuration");
expect(configButton).toBeInTheDocument();
});
});

View File

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

View File

@@ -1,38 +0,0 @@
import {
OAuthMetadata,
OAuthClientInformationFull,
OAuthClientInformation,
OAuthTokens,
} from "@modelcontextprotocol/sdk/shared/auth.js";
// OAuth flow steps
export type OAuthStep =
| "metadata_discovery"
| "client_registration"
| "authorization_redirect"
| "authorization_code"
| "token_request"
| "complete";
// Message types for inline feedback
export type MessageType = "success" | "error" | "info";
export interface StatusMessage {
type: MessageType;
message: string;
}
// Single state interface for OAuth state
export interface AuthDebuggerState {
isInitiatingAuth: boolean;
oauthTokens: OAuthTokens | null;
loading: boolean;
oauthStep: OAuthStep;
oauthMetadata: OAuthMetadata | null;
oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null;
authorizationUrl: string | null;
authorizationCode: string;
latestError: Error | null;
statusMessage: StatusMessage | null;
validationError: string | null;
}

View File

@@ -4,13 +4,11 @@ import {
OAuthClientInformation,
OAuthTokens,
OAuthTokensSchema,
OAuthClientMetadata,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(public serverUrl: string) {
constructor(private serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
@@ -19,7 +17,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
return window.location.origin + "/oauth/callback";
}
get clientMetadata(): OAuthClientMetadata {
get clientMetadata() {
return {
redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: "none",
@@ -103,38 +101,3 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
);
}
}
// Overrides debug URL and allows saving server OAuth metadata to
// display in debug UI.
export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider {
get redirectUrl(): string {
return `${window.location.origin}/oauth/callback/debug`;
}
saveServerMetadata(metadata: OAuthMetadata) {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(metadata));
}
getServerMetadata(): OAuthMetadata | null {
const key = getServerSpecificKey(
SESSION_KEYS.SERVER_METADATA,
this.serverUrl,
);
const metadata = sessionStorage.getItem(key);
if (!metadata) {
return null;
}
return JSON.parse(metadata);
}
clear() {
super.clear();
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl),
);
}
}

View File

@@ -6,7 +6,6 @@ export const SESSION_KEYS = {
SERVER_URL: "mcp_server_url",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
SERVER_METADATA: "mcp_server_metadata",
} as const;
// Generate server-specific session storage keys

View File

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

View File

@@ -2,12 +2,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
SSEClientTransport,
SseError,
SSEClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/sse.js";
import {
StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
ClientNotification,
ClientRequest,
@@ -31,7 +27,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js";
import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
@@ -54,7 +50,6 @@ interface UseConnectionOptions {
env: Record<string, string>;
bearerToken?: string;
headerName?: string;
customHeaders?: [string, string][];
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
@@ -72,7 +67,6 @@ export function useConnection({
env,
bearerToken,
headerName,
customHeaders,
config,
onNotification,
onStdErrNotification,
@@ -285,13 +279,34 @@ export function useConnection({
setConnectionStatus("error-connecting-to-proxy");
return;
}
let mcpProxyServerUrl;
switch (transportType) {
case "stdio":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
break;
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
try {
// Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first.
const headers = new Headers(customHeaders||[]);
//const headers: HeadersInit = [ ...customHeaders||[] ];
const headers: HeadersInit = {};
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
@@ -301,86 +316,25 @@ export function useConnection({
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
const authHeaderName = headerName || "Authorization";
headers.set(authHeaderName, `Bearer ${token}`);
headers[authHeaderName] = `Bearer ${token}`;
}
// Create appropriate transport
let transportOptions:
| StreamableHTTPClientTransportOptions
| SSEClientTransportOptions;
let mcpProxyServerUrl;
switch (transportType) {
case "stdio":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/stdio`);
mcpProxyServerUrl.searchParams.append("command", command);
mcpProxyServerUrl.searchParams.append("args", args);
mcpProxyServerUrl.searchParams.append("env", JSON.stringify(env));
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "sse":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
break;
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
const transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init: RequestInit | undefined,
) => fetch(url, { ...init, headers }),
},
requestInit: {
headers,
},
};
const clientTransport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions);

View File

@@ -1,181 +0,0 @@
import { OAuthStep, AuthDebuggerState } from "./auth-types";
import { DebugInspectorOAuthClientProvider } from "./auth";
import {
discoverOAuthMetadata,
registerClient,
startAuthorization,
exchangeAuthorization,
} from "@modelcontextprotocol/sdk/client/auth.js";
import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
export interface StateMachineContext {
state: AuthDebuggerState;
serverUrl: string;
provider: DebugInspectorOAuthClientProvider;
updateState: (updates: Partial<AuthDebuggerState>) => void;
}
export interface StateTransition {
canTransition: (context: StateMachineContext) => Promise<boolean>;
execute: (context: StateMachineContext) => Promise<void>;
nextStep: OAuthStep;
}
// State machine transitions
export const oauthTransitions: Record<OAuthStep, StateTransition> = {
metadata_discovery: {
canTransition: async () => true,
execute: async (context) => {
const metadata = await discoverOAuthMetadata(context.serverUrl);
if (!metadata) {
throw new Error("Failed to discover OAuth metadata");
}
const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata);
context.provider.saveServerMetadata(parsedMetadata);
context.updateState({
oauthMetadata: parsedMetadata,
oauthStep: "client_registration",
});
},
nextStep: "client_registration",
},
client_registration: {
canTransition: async (context) => !!context.state.oauthMetadata,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientMetadata = context.provider.clientMetadata;
// Add all supported scopes to client registration
if (metadata.scopes_supported) {
clientMetadata.scope = metadata.scopes_supported.join(" ");
}
const fullInformation = await registerClient(context.serverUrl, {
metadata,
clientMetadata,
});
context.provider.saveClientInformation(fullInformation);
context.updateState({
oauthClientInfo: fullInformation,
oauthStep: "authorization_redirect",
});
},
nextStep: "authorization_redirect",
},
authorization_redirect: {
canTransition: async (context) =>
!!context.state.oauthMetadata && !!context.state.oauthClientInfo,
execute: async (context) => {
const metadata = context.state.oauthMetadata!;
const clientInformation = context.state.oauthClientInfo!;
let scope: string | undefined = undefined;
if (metadata.scopes_supported) {
scope = metadata.scopes_supported.join(" ");
}
const { authorizationUrl, codeVerifier } = await startAuthorization(
context.serverUrl,
{
metadata,
clientInformation,
redirectUrl: context.provider.redirectUrl,
scope,
},
);
context.provider.saveCodeVerifier(codeVerifier);
context.updateState({
authorizationUrl: authorizationUrl.toString(),
oauthStep: "authorization_code",
});
},
nextStep: "authorization_code",
},
authorization_code: {
canTransition: async () => true,
execute: async (context) => {
if (
!context.state.authorizationCode ||
context.state.authorizationCode.trim() === ""
) {
context.updateState({
validationError: "You need to provide an authorization code",
});
// Don't advance if no code
throw new Error("Authorization code required");
}
context.updateState({
validationError: null,
oauthStep: "token_request",
});
},
nextStep: "token_request",
},
token_request: {
canTransition: async (context) => {
return (
!!context.state.authorizationCode &&
!!context.provider.getServerMetadata() &&
!!(await context.provider.clientInformation())
);
},
execute: async (context) => {
const codeVerifier = context.provider.codeVerifier();
const metadata = context.provider.getServerMetadata()!;
const clientInformation = (await context.provider.clientInformation())!;
const tokens = await exchangeAuthorization(context.serverUrl, {
metadata,
clientInformation,
authorizationCode: context.state.authorizationCode,
codeVerifier,
redirectUri: context.provider.redirectUrl,
});
context.provider.saveTokens(tokens);
context.updateState({
oauthTokens: tokens,
oauthStep: "complete",
});
},
nextStep: "complete",
},
complete: {
canTransition: async () => false,
execute: async () => {
// No-op for complete state
},
nextStep: "complete",
},
};
export class OAuthStateMachine {
constructor(
private serverUrl: string,
private updateState: (updates: Partial<AuthDebuggerState>) => void,
) {}
async executeStep(state: AuthDebuggerState): Promise<void> {
const provider = new DebugInspectorOAuthClientProvider(this.serverUrl);
const context: StateMachineContext = {
state,
serverUrl: this.serverUrl,
provider,
updateState: this.updateState,
};
const transition = oauthTransitions[state.oauthStep];
if (!(await transition.canTransition(context))) {
throw new Error(`Cannot transition from ${state.oauthStep}`);
}
await transition.execute(context);
}
}

View File

@@ -1,8 +1,5 @@
import { InspectorConfig } from "@/lib/configurationTypes";
import {
DEFAULT_MCP_PROXY_LISTEN_PORT,
DEFAULT_INSPECTOR_CONFIG,
} from "@/lib/constants";
import { DEFAULT_MCP_PROXY_LISTEN_PORT } from "@/lib/constants";
export const getMCPProxyAddress = (config: InspectorConfig): string => {
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS.value as string;
@@ -27,100 +24,3 @@ export const getMCPServerRequestMaxTotalTimeout = (
): number => {
return config.MCP_REQUEST_MAX_TOTAL_TIMEOUT.value as number;
};
const getSearchParam = (key: string): string | null => {
try {
const url = new URL(window.location.href);
return url.searchParams.get(key);
} catch {
return null;
}
};
export const getInitialTransportType = ():
| "stdio"
| "sse"
| "streamable-http" => {
const param = getSearchParam("transport");
if (param === "stdio" || param === "sse" || param === "streamable-http") {
return param;
}
return (
(localStorage.getItem("lastTransportType") as
| "stdio"
| "sse"
| "streamable-http") || "stdio"
);
};
export const getInitialSseUrl = (): string => {
const param = getSearchParam("serverUrl");
if (param) return param;
return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse";
};
export const getInitialCommand = (): string => {
const param = getSearchParam("serverCommand");
if (param) return param;
return localStorage.getItem("lastCommand") || "mcp-server-everything";
};
export const getInitialArgs = (): string => {
const param = getSearchParam("serverArgs");
if (param) return param;
return localStorage.getItem("lastArgs") || "";
};
// Returns a map of config key -> value from query params if present
export const getConfigOverridesFromQueryParams = (
defaultConfig: InspectorConfig,
): Partial<InspectorConfig> => {
const url = new URL(window.location.href);
const overrides: Partial<InspectorConfig> = {};
for (const key of Object.keys(defaultConfig)) {
const param = url.searchParams.get(key);
if (param !== null) {
// Try to coerce to correct type based on default value
const defaultValue = defaultConfig[key as keyof InspectorConfig].value;
let value: string | number | boolean = param;
if (typeof defaultValue === "number") {
value = Number(param);
} else if (typeof defaultValue === "boolean") {
value = param === "true";
}
overrides[key as keyof InspectorConfig] = {
...defaultConfig[key as keyof InspectorConfig],
value,
};
}
}
return overrides;
};
export const initializeInspectorConfig = (
localStorageKey: string,
): InspectorConfig => {
const savedConfig = localStorage.getItem(localStorageKey);
let baseConfig: InspectorConfig;
if (savedConfig) {
// merge default config with saved config
const mergedConfig = {
...DEFAULT_INSPECTOR_CONFIG,
...JSON.parse(savedConfig),
} as InspectorConfig;
// update description of keys to match the new description (in case of any updates to the default config description)
for (const [key, value] of Object.entries(mergedConfig)) {
mergedConfig[key as keyof InspectorConfig] = {
...value,
label: DEFAULT_INSPECTOR_CONFIG[key as keyof InspectorConfig].label,
};
}
baseConfig = mergedConfig;
} else {
baseConfig = DEFAULT_INSPECTOR_CONFIG;
}
// Apply query param overrides
const overrides = getConfigOverridesFromQueryParams(DEFAULT_INSPECTOR_CONFIG);
return { ...baseConfig, ...overrides };
};

View File

@@ -25,7 +25,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"types": ["jest", "@testing-library/jest-dom", "node", "react", "react-dom"]
"types": ["jest", "@testing-library/jest-dom", "node"]
},
"include": ["src"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 385 KiB

3735
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector",
"version": "0.12.0",
"version": "0.10.2",
"description": "Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -26,7 +26,6 @@
"build-server": "cd server && npm run build",
"build-client": "cd client && npm run build",
"build-cli": "cd cli && npm run build",
"clean": "rimraf ./node_modules ./client/node_modules ./cli/node_modules ./build ./client/dist ./server/build ./cli/build ./package-lock.json && npm install",
"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\"",
"start": "node client/bin/start.js",
@@ -36,16 +35,14 @@
"test-cli": "cd cli && npm run test",
"prettier-fix": "prettier --write .",
"prettier-check": "prettier --check .",
"prepare": "npm run build",
"publish-all": "npm publish --workspaces --access public && npm publish --access public"
},
"dependencies": {
"@modelcontextprotocol/inspector-cli": "^0.12.0",
"@modelcontextprotocol/inspector-client": "^0.12.0",
"@modelcontextprotocol/inspector-server": "^0.12.0",
"@modelcontextprotocol/sdk": "^1.11.2",
"@modelcontextprotocol/inspector-cli": "^0.10.2",
"@modelcontextprotocol/inspector-client": "^0.10.2",
"@modelcontextprotocol/inspector-server": "^0.10.2",
"@modelcontextprotocol/sdk": "^1.10.2",
"concurrently": "^9.0.1",
"open": "^10.1.0",
"shell-quote": "^1.8.2",
"spawn-rx": "^5.1.2",
"ts-node": "^10.9.2",
@@ -57,7 +54,6 @@
"@types/shell-quote": "^1.7.5",
"jest-fixed-jsdom": "^0.0.9",
"prettier": "3.3.3",
"rimraf": "^6.0.1",
"typescript": "^5.4.2"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-server",
"version": "0.12.0",
"version": "0.10.2",
"description": "Server-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -27,7 +27,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"@modelcontextprotocol/sdk": "^1.10.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"ws": "^8.18.0",

View File

@@ -21,14 +21,7 @@ import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js";
import { randomUUID } from "node:crypto";
const SSE_HEADERS_PASSTHROUGH = [
"authorization",
"x-api-key",
"x-custom-header",
"x-auth-token",
"x-request-id",
"x-correlation-id"
];
const SSE_HEADERS_PASSTHROUGH = ["authorization"];
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
"authorization",
"mcp-session-id",