Merge branch 'main' into main

This commit is contained in:
Cliff Hall
2025-04-17 16:40:48 -04:00
committed by GitHub
46 changed files with 3535 additions and 2391 deletions

120
client/bin/start.js Executable file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env node
import { resolve, dirname } from "path";
import { spawnPromise } from "spawn-rx";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url));
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms, true));
}
async function main() {
// Parse command line arguments
const args = process.argv.slice(2);
const envVars = {};
const mcpServerArgs = [];
let command = null;
let parsingFlags = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (parsingFlags && arg === "--") {
parsingFlags = false;
continue;
}
if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const envVar = args[++i];
const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value;
} else {
envVars[envVar] = "";
}
} else if (!command) {
command = arg;
} else {
mcpServerArgs.push(arg);
}
}
const inspectorServerPath = resolve(
__dirname,
"../..",
"server",
"build",
"index.js",
);
// Path to the client entry point
const inspectorClientPath = resolve(
__dirname,
"../..",
"client",
"bin",
"client.js",
);
const CLIENT_PORT = process.env.CLIENT_PORT ?? "6274";
const SERVER_PORT = process.env.SERVER_PORT ?? "6277";
console.log("Starting MCP inspector...");
const abort = new AbortController();
let cancelled = false;
process.on("SIGINT", () => {
cancelled = true;
abort.abort();
});
let server, serverOk;
try {
server = spawnPromise(
"node",
[
inspectorServerPath,
...(command ? [`--env`, command] : []),
...(mcpServerArgs ? [`--args=${mcpServerArgs.join(" ")}`] : []),
],
{
env: {
...process.env,
PORT: SERVER_PORT,
MCP_ENV_VARS: JSON.stringify(envVars),
},
signal: abort.signal,
echoOutput: true,
},
);
// Make sure server started before starting client
serverOk = await Promise.race([server, delay(2 * 1000)]);
} catch (error) {}
if (serverOk) {
try {
await spawnPromise("node", [inspectorClientPath], {
env: { ...process.env, PORT: CLIENT_PORT },
signal: abort.signal,
echoOutput: true,
});
} catch (e) {
if (!cancelled || process.env.DEBUG) throw e;
}
}
return 0;
}
main()
.then((_) => process.exit(0))
.catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.8.2",
"version": "0.10.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
@@ -8,14 +8,14 @@
"bugs": "https://github.com/modelcontextprotocol/inspector/issues",
"type": "module",
"bin": {
"mcp-inspector-client": "./bin/cli.js"
"mcp-inspector-client": "./bin/start.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"dev": "vite",
"dev": "vite --port 6274",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview --port 6274",
@@ -23,7 +23,7 @@
"test:watch": "jest --config jest.config.cjs --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.9.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",
@@ -72,6 +72,6 @@
"ts-jest": "^29.2.6",
"typescript": "^5.5.3",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8"
"vite": "^6.3.0"
}
}

View File

@@ -17,7 +17,13 @@ import {
Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react";
import React, {
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { useConnection } from "./lib/hooks/useConnection";
import { useDraggablePane } from "./lib/hooks/useDraggablePane";
import { StdErrNotification } from "./lib/notificationTypes";
@@ -46,14 +52,10 @@ import ToolsTab from "./components/ToolsTab";
import { DEFAULT_INSPECTOR_CONFIG } from "./lib/constants";
import { InspectorConfig } from "./lib/configurationTypes";
import { getMCPProxyAddress } from "./utils/configUtils";
import { useToast } from "@/hooks/use-toast";
const params = new URLSearchParams(window.location.search);
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
const App = () => {
const { toast } = useToast();
// Handle OAuth callback route
const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[]
@@ -122,6 +124,10 @@ const App = () => {
return localStorage.getItem("lastBearerToken") || "";
});
const [headerName, setHeaderName] = useState<string>(() => {
return localStorage.getItem("lastHeaderName") || "";
});
const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array<
PendingRequest & {
@@ -174,6 +180,7 @@ const App = () => {
sseUrl,
env,
bearerToken,
headerName,
config,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -213,35 +220,23 @@ const App = () => {
localStorage.setItem("lastBearerToken", bearerToken);
}, [bearerToken]);
useEffect(() => {
localStorage.setItem("lastHeaderName", headerName);
}, [headerName]);
useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]);
const hasProcessedRef = useRef(false);
// Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback)
useEffect(() => {
if (hasProcessedRef.current) {
// Only try to connect once
return;
}
const serverUrl = params.get("serverUrl");
if (serverUrl) {
// Auto-connect to previously saved serverURL after OAuth callback
const onOAuthConnect = useCallback(
(serverUrl: string) => {
setSseUrl(serverUrl);
setTransportType("sse");
// Remove serverUrl from URL without reloading the page
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete("serverUrl");
window.history.replaceState({}, "", newUrl.toString());
// Show success toast for OAuth
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
});
hasProcessedRef.current = true;
// Connect to the server
connectMcpServer();
}
}, [connectMcpServer, toast]);
void connectMcpServer();
},
[connectMcpServer],
);
useEffect(() => {
fetch(`${getMCPProxyAddress(config)}/config`)
@@ -472,13 +467,17 @@ const App = () => {
setLogLevel(level);
};
const clearStdErrNotifications = () => {
setStdErrNotifications([]);
};
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
<OAuthCallback onConnect={onOAuthConnect} />
</Suspense>
);
}
@@ -501,12 +500,15 @@ const App = () => {
setConfig={setConfig}
bearerToken={bearerToken}
setBearerToken={setBearerToken}
headerName={headerName}
setHeaderName={setHeaderName}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
clearStdErrNotifications={clearStdErrNotifications}
/>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor";
import { updateValueAtPath } from "@/utils/jsonUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType, JsonObject } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
interface DynamicJsonFormProps {
schema: JsonSchemaType;
@@ -14,13 +13,23 @@ interface DynamicJsonFormProps {
maxDepth?: number;
}
const isSimpleObject = (schema: JsonSchemaType): boolean => {
const supportedTypes = ["string", "number", "integer", "boolean", "null"];
if (supportedTypes.includes(schema.type)) return true;
if (schema.type !== "object") return false;
return Object.values(schema.properties ?? {}).every((prop) =>
supportedTypes.includes(prop.type),
);
};
const DynamicJsonForm = ({
schema,
value,
onChange,
maxDepth = 3,
}: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const isOnlyJSON = !isSimpleObject(schema);
const [isJsonMode, setIsJsonMode] = useState(isOnlyJSON);
const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
@@ -207,111 +216,6 @@ const DynamicJsonForm = ({
required={propSchema.required}
/>
);
case "object": {
// Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
{renderFormFields(
prop,
objectValue[key],
[...path, key],
depth + 1,
)}
</div>
))}
</div>
);
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null;
return (
<div className="space-y-4">
{propSchema.description && (
<p className="text-sm text-gray-600">{propSchema.description}</p>
)}
{propSchema.items?.description && (
<p className="text-sm text-gray-500">
Items: {propSchema.items.description}
</p>
)}
<div className="space-y-2">
{arrayValue.map((item, index) => (
<div key={index} className="flex items-center gap-2">
{renderFormFields(
propSchema.items as JsonSchemaType,
item,
[...path, index.toString()],
depth + 1,
)}
<Button
variant="outline"
size="sm"
onClick={() => {
const newArray = [...arrayValue];
newArray.splice(index, 1);
handleFieldChange(path, newArray);
}}
>
Remove
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [
...arrayValue,
defaultValue ?? null,
]);
}}
title={
propSchema.items?.description
? `Add new ${propSchema.items.description}`
: "Add new item"
}
>
Add Item
</Button>
</div>
</div>
);
}
default:
return null;
}
@@ -350,9 +254,11 @@ const DynamicJsonForm = ({
Format JSON
</Button>
)}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
{!isOnlyJSON && (
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button>
)}
</div>
{isJsonMode ? (

View File

@@ -227,7 +227,7 @@ const JsonNode = memo(
)}
<pre
className={clsx(
typeStyleMap.string,
isError ? typeStyleMap.error : typeStyleMap.string,
"break-all whitespace-pre-wrap",
)}
>

View File

@@ -22,7 +22,7 @@ const ListPane = <T extends object>({
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">

View File

@@ -1,9 +1,19 @@
import { useEffect, useRef } from "react";
import { authProvider } from "../lib/auth";
import { InspectorOAuthClientProvider } from "../lib/auth";
import { SESSION_KEYS } from "../lib/constants";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { useToast } from "@/hooks/use-toast.ts";
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
const OAuthCallback = () => {
interface OAuthCallbackProps {
onConnect: (serverUrl: string) => void;
}
const OAuthCallback = ({ onConnect }: OAuthCallbackProps) => {
const { toast } = useToast();
const hasProcessedRef = useRef(false);
useEffect(() => {
@@ -14,37 +24,56 @@ const OAuthCallback = () => {
}
hasProcessedRef.current = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
const notifyError = (description: string) =>
void toast({
title: "OAuth Authorization Error",
description,
variant: "destructive",
});
if (!code || !serverUrl) {
console.error("Missing code or server URL");
window.location.href = "/";
return;
const params = parseOAuthCallbackParams(window.location.search);
if (!params.successful) {
return notifyError(generateOAuthErrorDescription(params));
}
try {
const result = await auth(authProvider, {
serverUrl,
authorizationCode: code,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL);
if (!serverUrl) {
return notifyError("Missing Server URL");
}
// Redirect back to the main app with server URL to trigger auto-connect
window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`;
let result;
try {
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl);
result = await auth(serverAuthProvider, {
serverUrl,
authorizationCode: params.code,
});
} catch (error) {
console.error("OAuth callback error:", error);
window.location.href = "/";
return notifyError(`Unexpected error occurred: ${error}`);
}
if (result !== "AUTHORIZED") {
return notifyError(
`Expected to be authorized after providing auth code, got: ${result}`,
);
}
// Finally, trigger auto-connect
toast({
title: "Success",
description: "Successfully authenticated with OAuth",
variant: "default",
});
onConnect(serverUrl);
};
void handleCallback();
}, []);
handleCallback().finally(() => {
window.history.replaceState({}, document.title, "/");
});
}, [toast, onConnect]);
return (
<div className="flex items-center justify-center h-screen">

View File

@@ -43,7 +43,7 @@ const PromptsTab = ({
clearPrompts: () => void;
getPrompt: (name: string, args: Record<string, string>) => void;
selectedPrompt: Prompt | null;
setSelectedPrompt: (prompt: Prompt) => void;
setSelectedPrompt: (prompt: Prompt | null) => void;
handleCompletion: (
ref: PromptReference | ResourceReference,
argName: string,
@@ -89,7 +89,10 @@ const PromptsTab = ({
<ListPane
items={prompts}
listItems={listPrompts}
clearItems={clearPrompts}
clearItems={() => {
clearPrompts();
setSelectedPrompt(null);
}}
setSelectedItem={(prompt) => {
setSelectedPrompt(prompt);
setPromptArgs({});
@@ -108,7 +111,7 @@ const PromptsTab = ({
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3>

View File

@@ -104,7 +104,6 @@ const ResourcesTab = ({
if (selectedTemplate) {
const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues);
readResource(uri);
setSelectedTemplate(null);
// We don't have the full Resource object here, so we create a partial one
setSelectedResource({ uri, name: uri } as Resource);
}
@@ -116,7 +115,13 @@ const ResourcesTab = ({
<ListPane
items={resources}
listItems={listResources}
clearItems={clearResources}
clearItems={() => {
clearResources();
// Condition to check if selected resource is not resource template's resource
if (!selectedTemplate) {
setSelectedResource(null);
}
}}
setSelectedItem={(resource) => {
setSelectedResource(resource);
readResource(resource.uri);
@@ -139,7 +144,14 @@ const ResourcesTab = ({
<ListPane
items={resourceTemplates}
listItems={listResourceTemplates}
clearItems={clearResourceTemplates}
clearItems={() => {
clearResourceTemplates();
// Condition to check if selected resource is resource template's resource
if (selectedTemplate) {
setSelectedResource(null);
}
setSelectedTemplate(null);
}}
setSelectedItem={(template) => {
setSelectedTemplate(template);
setSelectedResource(null);
@@ -162,7 +174,7 @@ const ResourcesTab = ({
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center">
<h3
className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name}

View File

@@ -51,9 +51,12 @@ interface SidebarProps {
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
clearStdErrNotifications: () => void;
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
@@ -75,9 +78,12 @@ const Sidebar = ({
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
onConnect,
onDisconnect,
stdErrNotifications,
clearStdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
@@ -92,7 +98,7 @@ const Sidebar = ({
return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version}
@@ -175,6 +181,7 @@ const Sidebar = ({
variant="outline"
onClick={() => setShowBearerToken(!showBearerToken)}
className="flex items-center w-full"
data-testid="auth-button"
aria-expanded={showBearerToken}
>
{showBearerToken ? (
@@ -186,6 +193,16 @@ const Sidebar = ({
</Button>
{showBearerToken && (
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
@@ -197,6 +214,7 @@ const Sidebar = ({
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
@@ -432,7 +450,13 @@ const Sidebar = ({
<div className="space-y-2">
{connectionStatus === "connected" && (
<div className="grid grid-cols-2 gap-4">
<Button data-testid="connect-button" onClick={onConnect}>
<Button
data-testid="connect-button"
onClick={() => {
onDisconnect();
onConnect();
}}
>
<RotateCcw className="w-4 h-4 mr-2" />
{transportType === "stdio" ? "Restart" : "Reconnect"}
</Button>
@@ -499,7 +523,9 @@ const Sidebar = ({
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
@@ -509,9 +535,19 @@ const Sidebar = ({
{stdErrNotifications.length > 0 && (
<>
<div className="mt-4 border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium">
Error output from MCP server
</h3>
<Button
variant="outline"
size="sm"
onClick={clearStdErrNotifications}
className="h-8 px-2"
>
Clear
</Button>
</div>
<div className="mt-2 max-h-80 overflow-y-auto">
{stdErrNotifications.map((notification, index) => (
<div

View File

@@ -43,7 +43,13 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
useEffect(() => {
setParams({});
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
).map(([key, value]) => [
key,
generateDefaultValue(value as JsonSchemaType),
]);
setParams(Object.fromEntries(params));
}, [selectedTool]);
const renderToolResult = () => {
@@ -140,7 +146,7 @@ const ToolsTab = ({
/>
<div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200">
<div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"}
</h3>
@@ -217,13 +223,10 @@ const ToolsTab = ({
}}
/>
</div>
) : (
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type={
prop.type === "number" || prop.type === "integer"
? "number"
: "text"
}
type="number"
id={key}
name={key}
placeholder={prop.description}
@@ -231,15 +234,29 @@ const ToolsTab = ({
onChange={(e) =>
setParams({
...params,
[key]:
prop.type === "number" ||
prop.type === "integer"
? Number(e.target.value)
: e.target.value,
[key]: Number(e.target.value),
})
}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
}}
/>
</div>
)}
</div>
);

View File

@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
@@ -93,3 +93,47 @@ describe("DynamicJsonForm Integer Fields", () => {
});
});
});
describe("DynamicJsonForm Complex Fields", () => {
const renderForm = (props = {}) => {
const defaultProps = {
schema: {
type: "object",
properties: {
// The simplified JsonSchemaType does not accept oneOf fields
// But they exist in the more-complete JsonSchema7Type
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: undefined,
onChange: jest.fn(),
};
return render(<DynamicJsonForm {...defaultProps} {...props} />);
};
describe("Basic Operations", () => {
it("should render textbox and autoformat button, but no switch-to-form button", () => {
renderForm();
const input = screen.getByRole("textbox");
expect(input).toHaveProperty("type", "textarea");
const buttons = screen.getAllByRole("button");
expect(buttons).toHaveLength(1);
expect(buttons[0]).toHaveProperty("textContent", "Format JSON");
});
it("should pass changed values to onChange", () => {
const onChange = jest.fn();
renderForm({ onChange });
const input = screen.getByRole("textbox");
fireEvent.change(input, {
target: { value: `{ "nested": "i am string" }` },
});
// The onChange handler is debounced when using the JSON view, so we need to wait a little bit
waitFor(() => {
expect(onChange).toHaveBeenCalledWith(`{ "nested": "i am string" }`);
});
});
});
});

View File

@@ -1,4 +1,5 @@
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";
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
@@ -29,6 +30,7 @@ describe("Sidebar Environment Variables", () => {
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "info" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
@@ -108,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
});
});
describe("Authentication", () => {
const openAuthSection = () => {
const button = screen.getByTestId("auth-button");
fireEvent.click(button);
};
it("should update bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "new_token" } });
expect(setBearerToken).toHaveBeenCalledWith("new_token");
});
it("should update header name", () => {
const setHeaderName = jest.fn();
renderSidebar({
headerName: "Authorization",
setHeaderName,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
});
it("should clear bearer token", () => {
const setBearerToken = jest.fn();
renderSidebar({
bearerToken: "existing_token",
setBearerToken,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "" } });
expect(setBearerToken).toHaveBeenCalledWith("");
});
it("should properly render bearer token input", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain token visibility state after update", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
});
it("should maintain header name when toggling auth section", () => {
renderSidebar({
headerName: "X-API-Key",
transportType: "sse",
});
// Open auth section
openAuthSection();
// Verify header name is displayed
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveValue("X-API-Key");
// Close auth section
const authButton = screen.getByTestId("auth-button");
fireEvent.click(authButton);
// Reopen auth section
fireEvent.click(authButton);
// Verify header name is still preserved
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
});
it("should display default header name when not specified", () => {
renderSidebar({
headerName: undefined,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
});
});
describe("Key Editing", () => {
it("should maintain order when editing first key", () => {
const setEnv = jest.fn();

View File

@@ -54,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {

View File

@@ -2,8 +2,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {

View File

@@ -15,13 +15,6 @@ type ToasterToast = ToastProps & {
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
@@ -29,23 +22,28 @@ function genId() {
return count.toString();
}
type ActionType = typeof actionTypes;
const enum ActionType {
ADD_TOAST = "ADD_TOAST",
UPDATE_TOAST = "UPDATE_TOAST",
DISMISS_TOAST = "DISMISS_TOAST",
REMOVE_TOAST = "REMOVE_TOAST",
}
type Action =
| {
type: ActionType["ADD_TOAST"];
type: ActionType.ADD_TOAST;
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
type: ActionType.UPDATE_TOAST;
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
type: ActionType.DISMISS_TOAST;
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
type: ActionType.REMOVE_TOAST;
toastId?: ToasterToast["id"];
};
@@ -63,7 +61,7 @@ const addToRemoveQueue = (toastId: string) => {
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
type: ActionType.REMOVE_TOAST,
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
@@ -73,13 +71,13 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case ActionType.ADD_TOAST:
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
case ActionType.UPDATE_TOAST:
return {
...state,
toasts: state.toasts.map((t) =>
@@ -87,7 +85,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
case "DISMISS_TOAST": {
case ActionType.DISMISS_TOAST: {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
@@ -112,7 +110,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case ActionType.REMOVE_TOAST:
if (action.toastId === undefined) {
return {
...state,
@@ -144,13 +142,14 @@ function toast({ ...props }: Toast) {
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
type: ActionType.UPDATE_TOAST,
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const dismiss = () =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId: id });
dispatch({
type: "ADD_TOAST",
type: ActionType.ADD_TOAST,
toast: {
...props,
id,
@@ -184,7 +183,8 @@ function useToast() {
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
dismiss: (toastId?: string) =>
dispatch({ type: ActionType.DISMISS_TOAST, toastId }),
};
}

View File

@@ -5,9 +5,14 @@ import {
OAuthTokens,
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import { SESSION_KEYS } from "./constants";
import { SESSION_KEYS, getServerSpecificKey } from "./constants";
export class InspectorOAuthClientProvider implements OAuthClientProvider {
constructor(private serverUrl: string) {
// Save the server URL to session storage
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl);
}
class InspectorOAuthClientProvider implements OAuthClientProvider {
get redirectUrl() {
return window.location.origin + "/oauth/callback";
}
@@ -24,7 +29,11 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
async clientInformation() {
const value = sessionStorage.getItem(SESSION_KEYS.CLIENT_INFORMATION);
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
this.serverUrl,
);
const value = sessionStorage.getItem(key);
if (!value) {
return undefined;
}
@@ -33,14 +42,16 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveClientInformation(clientInformation: OAuthClientInformation) {
sessionStorage.setItem(
const key = getServerSpecificKey(
SESSION_KEYS.CLIENT_INFORMATION,
JSON.stringify(clientInformation),
this.serverUrl,
);
sessionStorage.setItem(key, JSON.stringify(clientInformation));
}
async tokens() {
const tokens = sessionStorage.getItem(SESSION_KEYS.TOKENS);
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
const tokens = sessionStorage.getItem(key);
if (!tokens) {
return undefined;
}
@@ -49,7 +60,8 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveTokens(tokens: OAuthTokens) {
sessionStorage.setItem(SESSION_KEYS.TOKENS, JSON.stringify(tokens));
const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl);
sessionStorage.setItem(key, JSON.stringify(tokens));
}
redirectToAuthorization(authorizationUrl: URL) {
@@ -57,17 +69,35 @@ class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveCodeVerifier(codeVerifier: string) {
sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier);
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
sessionStorage.setItem(key, codeVerifier);
}
codeVerifier() {
const verifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER);
const key = getServerSpecificKey(
SESSION_KEYS.CODE_VERIFIER,
this.serverUrl,
);
const verifier = sessionStorage.getItem(key);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
return verifier;
}
}
export const authProvider = new InspectorOAuthClientProvider();
clear() {
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CLIENT_INFORMATION, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl),
);
sessionStorage.removeItem(
getServerSpecificKey(SESSION_KEYS.CODE_VERIFIER, this.serverUrl),
);
}
}

View File

@@ -8,6 +8,15 @@ export const SESSION_KEYS = {
CLIENT_INFORMATION: "mcp_client_information",
} as const;
// Generate server-specific session storage keys
export const getServerSpecificKey = (
baseKey: string,
serverUrl?: string,
): string => {
if (!serverUrl) return baseKey;
return `[${serverUrl}] ${baseKey}`;
};
export type ConnectionStatus =
| "disconnected"
| "connected"

View File

@@ -17,6 +17,8 @@ const mockClient = {
connect: jest.fn().mockResolvedValue(undefined),
close: jest.fn(),
getServerCapabilities: jest.fn(),
getServerVersion: jest.fn(),
getInstructions: jest.fn(),
setNotificationHandler: jest.fn(),
setRequestHandler: jest.fn(),
};
@@ -43,9 +45,9 @@ jest.mock("@/hooks/use-toast", () => ({
// Mock the auth provider
jest.mock("../../auth", () => ({
authProvider: {
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue({ access_token: "mock-token" }),
},
})),
}));
describe("useConnection", () => {

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import {
ResourceReference,
PromptReference,
@@ -15,9 +15,11 @@ function debounce<T extends (...args: any[]) => PromiseLike<void>>(
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
timeout = setTimeout(() => {
void func(...args);
}, wait);
};
}
@@ -58,8 +60,8 @@ export function useCompletionState(
});
}, [cleanup]);
const requestCompletions = useCallback(
debounce(
const requestCompletions = useMemo(() => {
return debounce(
async (
ref: ResourceReference | PromptReference,
argName: string,
@@ -94,7 +96,7 @@ export function useCompletionState(
loading: { ...prev.loading, [argName]: false },
}));
}
} catch (err) {
} catch {
if (!abortController.signal.aborted) {
setState((prev) => ({
...prev,
@@ -108,9 +110,8 @@ export function useCompletionState(
}
},
debounceMs,
),
[handleCompletion, completionsSupported, cleanup, debounceMs],
);
);
}, [handleCompletion, completionsSupported, cleanup, debounceMs]);
// Clear completions when support status changes
useEffect(() => {

View File

@@ -28,10 +28,10 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { z } from "zod";
import { ConnectionStatus, SESSION_KEYS } from "../constants";
import { ConnectionStatus } from "../constants";
import { Notification, StdErrNotificationSchema } from "../notificationTypes";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import { authProvider } from "../auth";
import { InspectorOAuthClientProvider } from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
@@ -48,6 +48,7 @@ interface UseConnectionOptions {
sseUrl: string;
env: Record<string, string>;
bearerToken?: string;
headerName?: string;
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
@@ -64,6 +65,7 @@ export function useConnection({
sseUrl,
env,
bearerToken,
headerName,
config,
onNotification,
onStdErrNotification,
@@ -244,9 +246,10 @@ export function useConnection({
const handleAuthError = async (error: unknown) => {
if (error instanceof SseError && error.code === 401) {
sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl);
// Create a new auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
const result = await auth(authProvider, { serverUrl: sseUrl });
const result = await auth(serverAuthProvider, { serverUrl: sseUrl });
return result === "AUTHORIZED";
}
@@ -290,10 +293,15 @@ export function useConnection({
// proxying through the inspector server first.
const headers: HeadersInit = {};
// Create an auth provider with the current server URL
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
// Use manually provided bearer token if available, otherwise use OAuth tokens
const token = bearerToken || (await authProvider.tokens())?.access_token;
const token =
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
headers["Authorization"] = `Bearer ${token}`;
const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`;
}
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
@@ -332,8 +340,19 @@ export function useConnection({
);
}
let capabilities;
try {
await client.connect(clientTransport);
capabilities = client.getServerCapabilities();
const initializeRequest = {
method: "initialize",
};
pushHistory(initializeRequest, {
capabilities,
serverInfo: client.getServerVersion(),
instructions: client.getInstructions(),
});
} catch (error) {
console.error(
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
@@ -350,8 +369,6 @@ export function useConnection({
}
throw error;
}
const capabilities = client.getServerCapabilities();
setServerCapabilities(capabilities ?? null);
setCompletionsSupported(true); // Reset completions support on new connection
@@ -379,6 +396,8 @@ export function useConnection({
const disconnect = async () => {
await mcpClient?.close();
const authProvider = new InspectorOAuthClientProvider(sseUrl);
authProvider.clear();
setMcpClient(null);
setConnectionStatus("disconnected");
setCompletionsSupported(false);

View File

@@ -43,7 +43,10 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
document.documentElement.classList.toggle("dark", newTheme === "dark");
}
}, []);
return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
return useMemo(
() => [theme, setThemeWithSideEffect],
[theme, setThemeWithSideEffect],
);
};
export default useTheme;

View File

@@ -0,0 +1,78 @@
import {
generateOAuthErrorDescription,
parseOAuthCallbackParams,
} from "@/utils/oauthUtils.ts";
describe("parseOAuthCallbackParams", () => {
it("Returns successful: true and code when present", () => {
expect(parseOAuthCallbackParams("?code=fake-code")).toEqual({
successful: true,
code: "fake-code",
});
});
it("Returns successful: false and error when error is present", () => {
expect(parseOAuthCallbackParams("?error=access_denied")).toEqual({
successful: false,
error: "access_denied",
error_description: null,
error_uri: null,
});
});
it("Returns optional error metadata fields when present", () => {
const search =
"?error=access_denied&" +
"error_description=User%20Denied%20Request&" +
"error_uri=https%3A%2F%2Fexample.com%2Ferror-docs";
expect(parseOAuthCallbackParams(search)).toEqual({
successful: false,
error: "access_denied",
error_description: "User Denied Request",
error_uri: "https://example.com/error-docs",
});
});
it("Returns error when nothing present", () => {
expect(parseOAuthCallbackParams("?")).toEqual({
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
});
});
});
describe("generateOAuthErrorDescription", () => {
it("When only error is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: null,
error_uri: null,
}),
).toBe("Error: invalid_request.");
});
it("When error description is present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: null,
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.",
);
});
it("When all fields present", () => {
expect(
generateOAuthErrorDescription({
successful: false,
error: "invalid_request",
error_description: "The request could not be completed as dialed",
error_uri: "https://example.com/error-docs",
}),
).toEqual(
"Error: invalid_request.\nDetails: The request could not be completed as dialed.\nMore info: https://example.com/error-docs.",
);
});
});

View File

@@ -0,0 +1,65 @@
// The parsed query parameters returned by the Authorization Server
// representing either a valid authorization_code or an error
// ref: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.2
type CallbackParams =
| {
successful: true;
// The authorization code is generated by the authorization server.
code: string;
}
| {
successful: false;
// The OAuth 2.1 Error Code.
// Usually one of:
// ```
// invalid_request, unauthorized_client, access_denied, unsupported_response_type,
// invalid_scope, server_error, temporarily_unavailable
// ```
error: string;
// Human-readable ASCII text providing additional information, used to assist the
// developer in understanding the error that occurred.
error_description: string | null;
// A URI identifying a human-readable web page with information about the error,
// used to provide the client developer with additional information about the error.
error_uri: string | null;
};
export const parseOAuthCallbackParams = (location: string): CallbackParams => {
const params = new URLSearchParams(location);
const code = params.get("code");
if (code) {
return { successful: true, code };
}
const error = params.get("error");
const error_description = params.get("error_description");
const error_uri = params.get("error_uri");
if (error) {
return { successful: false, error, error_description, error_uri };
}
return {
successful: false,
error: "invalid_request",
error_description: "Missing code or error in response",
error_uri: null,
};
};
export const generateOAuthErrorDescription = (
params: Extract<CallbackParams, { successful: false }>,
): string => {
const error = params.error;
const errorDescription = params.error_description;
const errorUri = params.error_uri;
return [
`Error: ${error}.`,
errorDescription ? `Details: ${errorDescription}.` : "",
errorUri ? `More info: ${errorUri}.` : "",
]
.filter(Boolean)
.join("\n");
};