Merge branch 'main' into fixWrongRoleValue

This commit is contained in:
Bill Lau 2025-10-08 16:45:49 +08:00 • committed by GitHub
commit be65a8d569
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2275 additions and 712 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-cli",
"version": "0.16.7",
"version": "0.17.0",
"description": "CLI for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -1,5 +1,6 @@
#!/usr/bin/env node
import * as fs from "fs";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { Command } from "commander";
import {
@ -99,16 +100,27 @@ function createTransportOptions(
}
async function callMethod(args: Args): Promise<void> {
// Read package.json to get name and version for client identity
const pathA = "../package.json"; // We're in package @modelcontextprotocol/inspector-cli
const pathB = "../../package.json"; // We're in package @modelcontextprotocol/inspector
let packageJson: { name: string; version: string };
let packageJsonData = await import(fs.existsSync(pathA) ? pathA : pathB, {
with: { type: "json" },
});
packageJson = packageJsonData.default;
const transportOptions = createTransportOptions(
args.target,
args.transport,
args.headers,
);
const transport = createTransport(transportOptions);
const client = new Client({
name: "inspector-cli",
version: "0.5.1",
});
const [, name = packageJson.name] = packageJson.name.split("/");
const version = packageJson.version;
const clientIdentity = { name, version };
const client = new Client(clientIdentity);
try {
await connect(client, transport);

View file

@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/inspector-client",
"version": "0.16.7",
"version": "0.17.0",
"description": "Client-side application for the Model Context Protocol inspector",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",

View file

@ -22,6 +22,8 @@ import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
import { cleanParams } from "./utils/paramUtils";
import type { JsonSchemaType } from "./utils/jsonUtils";
import React, {
Suspense,
useCallback,
@ -107,6 +109,14 @@ const App = () => {
const [transportType, setTransportType] = useState<
"stdio" | "sse" | "streamable-http"
>(getInitialTransportType);
const [connectionType, setConnectionType] = useState<"direct" | "proxy">(
() => {
return (
(localStorage.getItem("lastConnectionType") as "direct" | "proxy") ||
"proxy"
);
},
);
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [roots, setRoots] = useState<Root[]>([]);
@ -154,12 +164,12 @@ const App = () => {
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
}
// Default to Authorization: Bearer as the most common case
// Default to empty array
return [
{
name: "Authorization",
value: "Bearer ",
enabled: true,
enabled: false,
},
];
});
@ -254,6 +264,7 @@ const App = () => {
oauthClientId,
oauthScope,
config,
connectionType,
onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]);
},
@ -338,6 +349,10 @@ const App = () => {
localStorage.setItem("lastTransportType", transportType);
}, [transportType]);
useEffect(() => {
localStorage.setItem("lastConnectionType", connectionType);
}, [connectionType]);
useEffect(() => {
if (bearerToken) {
localStorage.setItem("lastBearerToken", bearerToken);
@ -764,12 +779,18 @@ const App = () => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
// Find the tool schema to clean parameters properly
const tool = tools.find((t) => t.name === name);
const cleanedParams = tool?.inputSchema
? cleanParams(params, tool.inputSchema as JsonSchemaType)
: params;
const response = await sendMCPRequest(
{
method: "tools/call" as const,
params: {
name,
arguments: params,
arguments: cleanedParams,
_meta: {
progressToken: progressTokenRef.current++,
},
@ -780,6 +801,8 @@ const App = () => {
);
setToolResult(response);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
@ -791,6 +814,8 @@ const App = () => {
isError: true,
};
setToolResult(toolResult);
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
}
};
@ -882,6 +907,8 @@ const App = () => {
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
/>
<div
onMouseDown={handleSidebarDragStart}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
import { Search } from "lucide-react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { useState, useMemo, useRef } from "react";
type ListPaneProps<T> = {
items: T[];
@ -20,41 +23,111 @@ const ListPane = <T extends object>({
title,
buttonText,
isButtonDisabled,
}: ListPaneProps<T>) => (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<h3 className="font-semibold dark:text-white">{title}</h3>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{items.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
}: ListPaneProps<T>) => {
const [searchQuery, setSearchQuery] = useState("");
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
return items.filter((item) => {
const searchableText = [
(item as { name?: string }).name || "",
(item as { description?: string }).description || "",
]
.join(" ")
.toLowerCase();
return searchableText.includes(searchQuery.toLowerCase());
});
}, [items, searchQuery]);
const handleSearchClick = () => {
setIsSearchExpanded(true);
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
};
const handleSearchBlur = () => {
if (!searchQuery.trim()) {
setIsSearchExpanded(false);
}
};
return (
<div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-border">
<div className="flex items-center justify-between gap-4">
<h3 className="font-semibold dark:text-white flex-shrink-0">
{title}
</h3>
<div className="flex items-center justify-end min-w-0 flex-1">
{!isSearchExpanded ? (
<button
name="search"
aria-label="Search"
onClick={handleSearchClick}
className="p-2 hover:bg-gray-100 dark:hover:bg-secondary rounded-md transition-all duration-300 ease-in-out"
>
<Search className="w-4 h-4 text-muted-foreground" />
</button>
) : (
<div className="flex items-center w-full max-w-xs">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none z-10" />
<Input
ref={searchInputRef}
name="search"
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="pl-10 w-full transition-all duration-300 ease-in-out"
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
<div className="p-4">
<Button
variant="outline"
className="w-full mb-4"
onClick={listItems}
disabled={isButtonDisabled}
>
{buttonText}
</Button>
<Button
variant="outline"
className="w-full mb-4"
onClick={clearItems}
disabled={items.length === 0}
>
Clear
</Button>
<div className="space-y-2 overflow-y-auto max-h-96">
{filteredItems.map((item, index) => (
<div
key={index}
className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)}
>
{renderItem(item)}
</div>
))}
{filteredItems.length === 0 && searchQuery && items.length > 0 && (
<div className="text-center py-4 text-muted-foreground">
No items found matching &quot;{searchQuery}&quot;
</div>
)}
</div>
</div>
</div>
</div>
);
);
};
export default ListPane;

View file

@ -181,8 +181,8 @@ export const OAuthFlowProgress = ({
</p>
<p className="text-xs text-blue-600 mt-1">
Resource metadata was added in the{" "}
<a href="https://modelcontextprotocol.io/specification/draft/basic/authorization#2-3-1-authorization-server-location">
2025-DRAFT-v2 specification update
<a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-server-location">
2025-06-18 specification update
</a>
<br />
{authState.resourceMetadataError.message}

View file

@ -63,9 +63,7 @@ const PromptsTab = ({
clearCompletions();
}, [clearCompletions, selectedPrompt]);
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
const triggerCompletions = (argName: string, value: string) => {
if (selectedPrompt) {
requestCompletions(
{
@ -79,6 +77,16 @@ const PromptsTab = ({
}
};
const handleInputChange = async (argName: string, value: string) => {
setPromptArgs((prev) => ({ ...prev, [argName]: value }));
triggerCompletions(argName, value);
};
const handleFocus = async (argName: string) => {
const currentValue = promptArgs[argName] || "";
triggerCompletions(argName, currentValue);
};
const handleGetPrompt = () => {
if (selectedPrompt) {
getPrompt(selectedPrompt.name, promptArgs);
@ -143,6 +151,7 @@ const PromptsTab = ({
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
onFocus={() => handleFocus(arg.name)}
options={completions[arg.name] || []}
/>

View file

@ -67,6 +67,8 @@ interface SidebarProps {
loggingSupported: boolean;
config: InspectorConfig;
setConfig: (config: InspectorConfig) => void;
connectionType: "direct" | "proxy";
setConnectionType: (type: "direct" | "proxy") => void;
}
const Sidebar = ({
@ -94,6 +96,8 @@ const Sidebar = ({
loggingSupported,
config,
setConfig,
connectionType,
setConnectionType,
}: SidebarProps) => {
const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false);
@ -104,6 +108,8 @@ const Sidebar = ({
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
const connectionTypeTip =
"Connect to server directly (requires CORS config on server) or via MCP Inspector Proxy";
// Reusable error reporter for copy actions
const reportError = useCallback(
(error: unknown) => {
@ -313,6 +319,35 @@ const Sidebar = ({
/>
)}
</div>
{/* Connection Type switch - only visible for non-STDIO transport types */}
<Tooltip>
<TooltipTrigger asChild>
<div className="space-y-2">
<label
className="text-sm font-medium"
htmlFor="connection-type-select"
>
Connection Type
</label>
<Select
value={connectionType}
onValueChange={(value: "direct" | "proxy") =>
setConnectionType(value)
}
>
<SelectTrigger id="connection-type-select">
<SelectValue placeholder="Select connection type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="proxy">Via Proxy</SelectItem>
<SelectItem value="direct">Direct</SelectItem>
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent>{connectionTypeTip}</TooltipContent>
</Tooltip>
</>
)}

View file

@ -1,11 +1,11 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm from "./DynamicJsonForm";
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import {
generateDefaultValue,
@ -22,10 +22,11 @@ import {
Send,
ChevronDown,
ChevronUp,
AlertCircle,
Copy,
CheckCheck,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
@ -45,6 +46,7 @@ const ToolsTab = ({
setSelectedTool,
toolResult,
nextCursor,
error,
resourceContent,
onReadResource,
}: {
@ -64,9 +66,20 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const { toast } = useToast();
const { copied, setCopied } = useCopy();
// Function to check if any form has validation errors
const checkValidationErrors = () => {
const errors = Object.values(formRefs.current).some(
(ref) => ref && !ref.validateJson().isValid,
);
setHasValidationErrors(errors);
return errors;
};
useEffect(() => {
const params = Object.entries(
selectedTool?.inputSchema.properties ?? [],
@ -79,6 +92,12 @@ const ToolsTab = ({
),
]);
setParams(Object.fromEntries(params));
// Reset validation errors when switching tools
setHasValidationErrors(false);
// Clear form refs for the previous tool
formRefs.current = {};
}, [selectedTool]);
return (
@ -114,6 +133,13 @@ const ToolsTab = ({
<div className="p-4">
{selectedTool ? (
<div className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
{selectedTool.description}
</p>
@ -159,21 +185,33 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
onChange={(e) =>
setParams({
...params,
[key]:
e.target.value === ""
? undefined
: e.target.value,
})
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
// Field cleared - set to undefined
setParams({
...params,
[key]: undefined,
});
} else {
// Field has value - keep as string
setParams({
...params,
[key]: value,
});
}
}}
className="mt-1"
/>
) : prop.type === "object" || prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
schema={{
type: prop.type,
properties: prop.properties,
@ -189,6 +227,8 @@ const ToolsTab = ({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
@ -199,19 +239,42 @@ const ToolsTab = ({
id={key}
name={key}
placeholder={prop.description}
value={(params[key] as string) ?? ""}
value={
params[key] === undefined
? ""
: String(params[key])
}
onChange={(e) => {
const value = e.target.value;
setParams({
...params,
[key]: value === "" ? "" : Number(value),
});
if (value === "") {
// Field cleared - set to undefined
setParams({
...params,
[key]: undefined,
});
} else {
// Field has value - try to convert to number, but store input either way
const num = Number(value);
if (!isNaN(num)) {
setParams({
...params,
[key]: num,
});
} else {
// Store invalid input as string - let server validate
setParams({
...params,
[key]: value,
});
}
}
}}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
schema={{
type: prop.type,
properties: prop.properties,
@ -224,6 +287,8 @@ const ToolsTab = ({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
@ -305,6 +370,9 @@ const ToolsTab = ({
<div className="flex gap-2">
<Button
onClick={async () => {
// Validate JSON inputs before calling tool
if (checkValidationErrors()) return;
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
@ -312,7 +380,7 @@ const ToolsTab = ({
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
disabled={isToolRunning || hasValidationErrors}
>
{isToolRunning ? (
<>

View file

@ -1,7 +1,8 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, expect, jest } from "@jest/globals";
import DynamicJsonForm from "../DynamicJsonForm";
import { useRef } from "react";
import DynamicJsonForm, { DynamicJsonFormRef } from "../DynamicJsonForm";
import type { JsonSchemaType } from "@/utils/jsonUtils";
describe("DynamicJsonForm String Fields", () => {
@ -663,3 +664,221 @@ describe("DynamicJsonForm Copy JSON Functionality", () => {
});
});
});
describe("DynamicJsonForm Validation Functionality", () => {
const renderFormWithRef = (props = {}) => {
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
const defaultProps = {
schema: {
type: "object",
properties: {
nested: { oneOf: [{ type: "string" }, { type: "integer" }] },
},
} as unknown as JsonSchemaType,
value: { nested: "test value" },
onChange: jest.fn(),
ref: formRef,
};
return (
<div>
<DynamicJsonForm {...defaultProps} {...props} />
<button
onClick={() => {
const result = formRef.current?.validateJson();
// Add data attributes to make validation result testable
const button = document.querySelector(
'[data-testid="validate-button"]',
) as HTMLElement;
if (button && result) {
button.setAttribute(
"data-validation-valid",
result.isValid.toString(),
);
button.setAttribute(
"data-validation-error",
result.error || "",
);
}
}}
data-testid="validate-button"
>
Validate
</button>
</div>
);
};
return render(<TestComponent />);
};
describe("validateJson method", () => {
it("should return valid for form mode", () => {
const simpleSchema = {
type: "string" as const,
description: "Test string field",
};
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
return (
<div>
<DynamicJsonForm
ref={formRef}
schema={simpleSchema}
value="test"
onChange={jest.fn()}
/>
<button
onClick={() => {
const result = formRef.current?.validateJson();
const button = document.querySelector(
'[data-testid="validate-button"]',
) as HTMLElement;
if (button && result) {
button.setAttribute(
"data-validation-valid",
result.isValid.toString(),
);
button.setAttribute(
"data-validation-error",
result.error || "",
);
}
}}
data-testid="validate-button"
>
Validate
</button>
</div>
);
};
render(<TestComponent />);
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should return valid for valid JSON in JSON mode", () => {
renderFormWithRef();
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should return invalid for malformed JSON in JSON mode", async () => {
renderFormWithRef();
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: '{ "invalid": json }' } });
// Wait a bit for any debounced updates
await waitFor(() => {
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe(
"false",
);
expect(validateButton.getAttribute("data-validation-error")).toContain(
"JSON",
);
});
});
it("should return valid for empty JSON in JSON mode", () => {
renderFormWithRef();
// Clear the textarea
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, { target: { value: "" } });
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
expect(validateButton.getAttribute("data-validation-valid")).toBe("true");
expect(validateButton.getAttribute("data-validation-error")).toBe("");
});
it("should set error state when validation fails", async () => {
renderFormWithRef();
// Enter invalid JSON
const textarea = screen.getByRole("textbox");
fireEvent.change(textarea, {
target: { value: '{ "trailing": "comma", }' },
});
// Trigger validation
const validateButton = screen.getByTestId("validate-button");
fireEvent.click(validateButton);
// Check that validation result shows error
expect(validateButton.getAttribute("data-validation-valid")).toBe(
"false",
);
expect(validateButton.getAttribute("data-validation-error")).toContain(
"JSON",
);
});
});
describe("forwardRef functionality", () => {
it("should expose validateJson method through ref", () => {
const TestComponent = () => {
const formRef = useRef<DynamicJsonFormRef>(null);
return (
<div>
<DynamicJsonForm
ref={formRef}
schema={{
type: "object",
properties: {
test: { type: "string" },
},
}}
value={{ test: "value" }}
onChange={jest.fn()}
/>
<button
onClick={() => {
const hasValidateMethod =
typeof formRef.current?.validateJson === "function";
const button = document.querySelector(
'[data-testid="ref-test-button"]',
) as HTMLElement;
if (button) {
button.setAttribute(
"data-has-validate-method",
hasValidateMethod.toString(),
);
}
}}
data-testid="ref-test-button"
>
Test Ref
</button>
</div>
);
};
render(<TestComponent />);
const testButton = screen.getByTestId("ref-test-button");
fireEvent.click(testButton);
expect(testButton.getAttribute("data-has-validate-method")).toBe("true");
});
});
});

View file

@ -0,0 +1,203 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, beforeEach, jest } from "@jest/globals";
import ListPane from "../ListPane";
describe("ListPane", () => {
const mockItems = [
{ id: 1, name: "Tool 1", description: "First tool" },
{ id: 2, name: "Tool 2", description: "Second tool" },
{ id: 3, name: "Another Tool", description: "Third tool" },
];
const defaultProps = {
items: mockItems,
listItems: jest.fn(),
clearItems: jest.fn(),
setSelectedItem: jest.fn(),
renderItem: (item: (typeof mockItems)[0]) => <div>{item.name}</div>,
title: "List tools",
buttonText: "Load Tools",
};
const renderListPane = (props = {}) => {
return render(<ListPane {...defaultProps} {...props} />);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe("Rendering", () => {
it("should render with title and button", () => {
renderListPane();
expect(screen.getByText("List tools")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Load Tools" }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument();
});
it("should render items when provided", () => {
renderListPane();
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});
it("should render empty state when no items", () => {
renderListPane({ items: [] });
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});
it("should render custom item content", () => {
const customRenderItem = (item: (typeof mockItems)[0]) => (
<div>
<span>{item.name}</span>
<small>{item.description}</small>
</div>
);
renderListPane({ renderItem: customRenderItem });
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("First tool")).toBeInTheDocument();
});
});
describe("Search Functionality", () => {
it("should show search icon initially", () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
expect(searchButton).toBeInTheDocument();
expect(searchButton.querySelector("svg")).toBeInTheDocument();
});
it("should expand search input when search icon is clicked", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
expect(searchInput).toBeInTheDocument();
// Wait for the setTimeout to complete and focus to be set
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(searchInput).toHaveFocus();
});
it("should filter items based on search query", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "Tool" } });
});
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.getByText("Tool 2")).toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
await act(async () => {
fireEvent.change(searchInput, { target: { value: "Another" } });
});
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
expect(screen.getByText("Another Tool")).toBeInTheDocument();
});
it("should show 'No items found of matching \"NonExistent\"' when search has no results", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "NonExistent" } });
});
expect(
screen.getByText('No items found matching "NonExistent"'),
).toBeInTheDocument();
expect(screen.queryByText("Tool 1")).not.toBeInTheDocument();
});
it("should collapse search when input is empty and loses focus", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.change(searchInput, { target: { value: "" } });
fireEvent.blur(searchInput);
});
const searchButtonAfterCollapse = screen.getByRole("button", {
name: "Search",
});
expect(searchButtonAfterCollapse).toBeInTheDocument();
expect(searchButtonAfterCollapse).not.toHaveClass("opacity-0");
});
it("should keep search expanded when input has content and loses focus", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "test" } });
fireEvent.blur(searchInput);
});
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("should search through all item properties (description)", async () => {
renderListPane();
const searchButton = screen.getByRole("button", { name: "Search" });
await act(async () => {
fireEvent.click(searchButton);
});
const searchInput = screen.getByPlaceholderText("Search...");
await act(async () => {
fireEvent.change(searchInput, { target: { value: "First tool" } });
});
expect(screen.getByText("Tool 1")).toBeInTheDocument();
expect(screen.queryByText("Tool 2")).not.toBeInTheDocument();
});
});
});

View file

@ -59,6 +59,8 @@ describe("Sidebar", () => {
loggingSupported: true,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
connectionType: "proxy" as const,
setConnectionType: jest.fn(),
};
const renderSidebar = (props = {}) => {

View file

@ -637,4 +637,189 @@ describe("ToolsTab", () => {
expect(screen.getByText(/version/i)).toBeInTheDocument();
});
});
describe("JSON Validation Integration", () => {
const toolWithJsonParams: Tool = {
name: "jsonTool",
description: "Tool with JSON parameters",
inputSchema: {
type: "object" as const,
required: ["config", "data"], // Make them required so they render as form fields
properties: {
config: {
type: "object" as const,
// No properties defined - this will force JSON mode
},
data: {
type: "array" as const,
// No items defined - this will force JSON mode
},
},
},
};
it("should prevent tool execution when JSON validation fails", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
tools: [toolWithJsonParams],
selectedTool: toolWithJsonParams,
callTool: mockCallTool,
});
// Find JSON editor textareas (there should be at least 1 for JSON parameters)
const textareas = screen.getAllByRole("textbox");
expect(textareas.length).toBeGreaterThanOrEqual(1);
// Enter invalid JSON in the first textarea
const configTextarea = textareas[0];
fireEvent.change(configTextarea, {
target: { value: '{ "invalid": json }' },
});
// Try to run the tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should not have been called due to validation failure
expect(mockCallTool).not.toHaveBeenCalled();
});
it("should allow tool execution when JSON validation passes", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
tools: [toolWithJsonParams],
selectedTool: toolWithJsonParams,
callTool: mockCallTool,
});
// Find JSON editor textareas (should have one for each required field: config and data)
const textareas = screen.getAllByRole("textbox");
expect(textareas.length).toBe(2);
// Enter valid JSON in each textarea
fireEvent.change(textareas[0], {
target: { value: '{ "setting": "value" }' },
});
fireEvent.change(textareas[1], {
target: { value: '["item1", "item2"]' },
});
// Wait for debounced updates
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
// Try to run the tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should have been called successfully
expect(mockCallTool).toHaveBeenCalled();
});
it("should handle mixed valid and invalid JSON parameters", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
tools: [toolWithJsonParams],
selectedTool: toolWithJsonParams,
callTool: mockCallTool,
});
const textareas = screen.getAllByRole("textbox");
// Enter invalid JSON that contains both valid and invalid parts
fireEvent.change(textareas[0], {
target: {
value:
'{ "config": { "setting": "value" }, "data": ["unclosed array" }',
},
});
// Try to run the tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should not have been called due to validation failure
expect(mockCallTool).not.toHaveBeenCalled();
});
it("should work with tools that have no JSON parameters", async () => {
const mockCallTool = jest.fn();
const simpleToolWithStringParam: Tool = {
name: "simpleTool",
description: "Tool with simple parameters",
inputSchema: {
type: "object" as const,
properties: {
message: { type: "string" as const },
count: { type: "number" as const },
},
},
};
renderToolsTab({
tools: [simpleToolWithStringParam],
selectedTool: simpleToolWithStringParam,
callTool: mockCallTool,
});
// Fill in the simple parameters
const messageInput = screen.getByRole("textbox");
const countInput = screen.getByRole("spinbutton");
fireEvent.change(messageInput, { target: { value: "test message" } });
fireEvent.change(countInput, { target: { value: "5" } });
// Run the tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should have been called successfully (no JSON validation needed)
expect(mockCallTool).toHaveBeenCalledWith(
simpleToolWithStringParam.name,
{
message: "test message",
count: 5,
},
);
});
it("should handle empty JSON parameters correctly", async () => {
const mockCallTool = jest.fn();
renderToolsTab({
tools: [toolWithJsonParams],
selectedTool: toolWithJsonParams,
callTool: mockCallTool,
});
const textareas = screen.getAllByRole("textbox");
expect(textareas.length).toBe(2);
// Clear both textareas (empty JSON should be valid)
fireEvent.change(textareas[0], { target: { value: "{}" } });
fireEvent.change(textareas[1], { target: { value: "[]" } });
// Wait for debounced updates
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
// Try to run the tool
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should have been called (empty JSON is considered valid)
expect(mockCallTool).toHaveBeenCalled();
});
});
});

View file

@ -19,6 +19,7 @@ interface ComboboxProps {
value: string;
onChange: (value: string) => void;
onInputChange: (value: string) => void;
onFocus?: () => void;
options: string[];
placeholder?: string;
emptyMessage?: string;
@ -29,6 +30,7 @@ export function Combobox({
value,
onChange,
onInputChange,
onFocus,
options = [],
placeholder = "Select...",
emptyMessage = "No results found.",
@ -36,6 +38,16 @@ export function Combobox({
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const handleOpenChange = React.useCallback(
(newOpen: boolean) => {
setOpen(newOpen);
if (newOpen && onFocus) {
onFocus();
}
},
[onFocus],
);
const handleSelect = React.useCallback(
(option: string) => {
onChange(option);
@ -52,7 +64,7 @@ export function Combobox({
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"

View file

@ -157,17 +157,10 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
}
saveClientInformation(clientInformation: OAuthClientInformation) {
// Remove client_secret before storing (not needed after initial OAuth flow)
const safeInfo = Object.fromEntries(
Object.entries(clientInformation).filter(
([key]) => key !== "client_secret",
),
) as OAuthClientInformation;
// Save the dynamically registered client information to session storage
saveClientInformationToSessionStorage({
serverUrl: this.serverUrl,
clientInformation: safeInfo,
clientInformation,
isPreregistered: false,
});
}

View file

@ -1,4 +1,12 @@
import { InspectorConfig } from "./configurationTypes";
import packageJson from "../../package.json";
// Client identity for MCP connections
export const CLIENT_IDENTITY = (() => {
const [, name = packageJson.name] = packageJson.name.split("/");
const version = packageJson.version;
return { name, version };
})();
// OAuth-related session storage keys
export const SESSION_KEYS = {

View file

@ -2,7 +2,7 @@ import { renderHook, act } from "@testing-library/react";
import { useConnection } from "../useConnection";
import { z } from "zod";
import { ClientRequest } from "@modelcontextprotocol/sdk/types.js";
import { DEFAULT_INSPECTOR_CONFIG } from "../../constants";
import { DEFAULT_INSPECTOR_CONFIG, CLIENT_IDENTITY } from "../../constants";
import {
SSEClientTransportOptions,
SseError,
@ -18,6 +18,9 @@ import { CustomHeaders } from "../../types/customHeaders";
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ status: "ok" }),
headers: {
get: jest.fn().mockReturnValue(null),
},
});
// Mock the SDK dependencies
@ -94,9 +97,10 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
}));
// Mock the toast hook
const mockToast = jest.fn();
jest.mock("@/lib/hooks/useToast", () => ({
useToast: () => ({
toast: jest.fn(),
toast: mockToast,
}),
}));
@ -248,8 +252,8 @@ describe("useConnection", () => {
expect(Client).toHaveBeenCalledWith(
expect.objectContaining({
name: "mcp-inspector",
version: expect.any(String),
name: CLIENT_IDENTITY.name,
version: CLIENT_IDENTITY.version,
}),
expect.objectContaining({
capabilities: expect.objectContaining({
@ -574,9 +578,10 @@ describe("useConnection", () => {
mockStreamableHTTPTransport.options = undefined;
});
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured", async () => {
test("sends X-MCP-Proxy-Auth header when proxy auth token is configured for proxy connectionType", async () => {
const propsWithProxyAuth = {
...defaultProps,
connectionType: "proxy" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
@ -626,6 +631,56 @@ describe("useConnection", () => {
).toHaveProperty("X-MCP-Proxy-Auth", "Bearer test-proxy-token");
});
test("does NOT send X-MCP-Proxy-Auth header when proxy auth token is configured for direct connectionType", async () => {
const propsWithProxyAuth = {
...defaultProps,
connectionType: "direct" as const,
config: {
...DEFAULT_INSPECTOR_CONFIG,
MCP_PROXY_AUTH_TOKEN: {
...DEFAULT_INSPECTOR_CONFIG.MCP_PROXY_AUTH_TOKEN,
value: "test-proxy-token",
},
},
};
const { result } = renderHook(() => useConnection(propsWithProxyAuth));
await act(async () => {
await result.current.connect();
});
// Check that the transport was created with the correct headers
expect(mockSSETransport.options).toBeDefined();
expect(mockSSETransport.options?.requestInit).toBeDefined();
// Verify that X-MCP-Proxy-Auth header is NOT present for direct connections
expect(mockSSETransport.options?.requestInit?.headers).not.toHaveProperty(
"X-MCP-Proxy-Auth",
);
expect(mockSSETransport?.options?.fetch).toBeDefined();
// Verify the fetch function does NOT include the proxy auth header
const mockFetch = mockSSETransport.options?.fetch;
const testUrl = "http://test.com";
await mockFetch?.(testUrl, {
headers: {
Accept: "text/event-stream",
},
cache: "no-store",
mode: "cors",
signal: new AbortController().signal,
redirect: "follow",
credentials: "include",
});
expect(global.fetch).toHaveBeenCalledTimes(1);
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(testUrl);
expect(
(global.fetch as jest.Mock).mock.calls[0][1].headers,
).not.toHaveProperty("X-MCP-Proxy-Auth");
});
test("does NOT send Authorization header for proxy auth", async () => {
const propsWithProxyAuth = {
...defaultProps,
@ -859,6 +914,42 @@ describe("useConnection", () => {
expect(headers).toHaveProperty("Authorization", "Bearer mock-token");
});
test("warns of enabled empty Bearer token", async () => {
// This test prevents regression of the bug where default "Bearer " header
// prevented OAuth token injection, causing infinite auth loops
const customHeaders: CustomHeaders = [
{
name: "Authorization",
value: "Bearer ", // Empty Bearer token placeholder
enabled: true, // enabled
},
];
const propsWithEmptyBearer = {
...defaultProps,
customHeaders,
};
const { result } = renderHook(() => useConnection(propsWithEmptyBearer));
await act(async () => {
await result.current.connect();
});
const headers = mockSSETransport.options?.requestInit?.headers;
expect(headers).toHaveProperty("Authorization", "Bearer");
// Should not have the x-custom-auth-headers since Authorization is standard
expect(headers).not.toHaveProperty("x-custom-auth-headers");
// Should show toast notification for empty Authorization header
expect(mockToast).toHaveBeenCalledWith({
title: "Invalid Authorization Header",
description: expect.any(String),
variant: "destructive",
});
});
test("prioritizes custom headers over legacy auth", async () => {
const customHeaders: CustomHeaders = [
{ name: "Authorization", value: "Bearer custom-token", enabled: true },
@ -882,6 +973,57 @@ describe("useConnection", () => {
});
});
describe("Connection URL Verification", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset the mock transport objects
mockSSETransport.url = undefined;
mockSSETransport.options = undefined;
mockStreamableHTTPTransport.url = undefined;
mockStreamableHTTPTransport.options = undefined;
});
test("uses server URL directly when connectionType is 'direct'", async () => {
const directProps = {
...defaultProps,
connectionType: "direct" as const,
};
const { result } = renderHook(() => useConnection(directProps));
await act(async () => {
await result.current.connect();
});
// Verify the transport was created with the direct server URL
expect(mockSSETransport.url).toBeDefined();
expect(mockSSETransport.url?.toString()).toBe("http://localhost:8080/");
});
test("uses proxy server URL when connectionType is 'proxy'", async () => {
const proxyProps = {
...defaultProps,
connectionType: "proxy" as const,
};
const { result } = renderHook(() => useConnection(proxyProps));
await act(async () => {
await result.current.connect();
});
// Verify the transport was created with a proxy server URL
expect(mockSSETransport.url).toBeDefined();
expect(mockSSETransport.url?.pathname).toBe("/sse");
expect(mockSSETransport.url?.searchParams.get("url")).toBe(
"http://localhost:8080",
);
expect(mockSSETransport.url?.searchParams.get("transportType")).toBe(
"sse",
);
});
});
describe("OAuth Error Handling with Scope Discovery", () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -35,7 +35,7 @@ import { RequestOptions } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { useEffect, useState } from "react";
import { useToast } from "@/lib/hooks/useToast";
import { z } from "zod";
import { ConnectionStatus } from "../constants";
import { ConnectionStatus, CLIENT_IDENTITY } from "../constants";
import { Notification } from "../notificationTypes";
import {
auth,
@ -47,7 +47,6 @@ import {
saveClientInformationToSessionStorage,
discoverScopes,
} from "../auth";
import packageJson from "../../../package.json";
import {
getMCPProxyAddress,
getMCPServerRequestMaxTotalTimeout,
@ -70,6 +69,7 @@ interface UseConnectionOptions {
oauthClientId?: string;
oauthScope?: string;
config: InspectorConfig;
connectionType?: "direct" | "proxy";
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -91,6 +91,7 @@ export function useConnection({
oauthClientId,
oauthScope,
config,
connectionType = "proxy",
onNotification,
onPendingRequest,
onElicitationRequest,
@ -110,6 +111,10 @@ export function useConnection({
{ request: string; response?: string }[]
>([]);
const [completionsSupported, setCompletionsSupported] = useState(false);
const [mcpSessionId, setMcpSessionId] = useState<string | null>(null);
const [mcpProtocolVersion, setMcpProtocolVersion] = useState<string | null>(
null,
);
useEffect(() => {
if (!oauthClientId) {
@ -346,28 +351,41 @@ export function useConnection({
return false;
};
const captureResponseHeaders = (response: Response): void => {
const sessionId = response.headers.get("mcp-session-id");
const protocolVersion = response.headers.get("mcp-protocol-version");
if (sessionId && sessionId !== mcpSessionId) {
setMcpSessionId(sessionId);
}
if (protocolVersion && protocolVersion !== mcpProtocolVersion) {
setMcpProtocolVersion(protocolVersion);
}
};
const connect = async (_e?: unknown, retryCount: number = 0) => {
const client = new Client<Request, Notification, Result>(
{
name: "mcp-inspector",
version: packageJson.version,
},
{
capabilities: {
sampling: {},
elicitation: {},
roots: {
listChanged: true,
},
const clientCapabilities = {
capabilities: {
sampling: {},
elicitation: {},
roots: {
listChanged: true,
},
},
};
const client = new Client<Request, Notification, Result>(
CLIENT_IDENTITY,
clientCapabilities,
);
try {
await checkProxyHealth();
} catch {
setConnectionStatus("error-connecting-to-proxy");
return;
// Only check proxy health for proxy connections
if (connectionType === "proxy") {
try {
await checkProxyHealth();
} catch {
setConnectionStatus("error-connecting-to-proxy");
return;
}
}
let lastRequest = "";
@ -382,11 +400,37 @@ export function useConnection({
// Use custom headers (migration is handled in App.tsx)
let finalHeaders: CustomHeaders = customHeaders || [];
// Add OAuth token if available and no custom headers are set
if (finalHeaders.length === 0) {
const isEmptyAuthHeader = (header: CustomHeaders[number]) =>
header.name.trim().toLowerCase() === "authorization" &&
header.value.trim().toLowerCase() === "bearer";
// Check for empty Authorization headers and show validation error
const hasEmptyAuthHeader = finalHeaders.some(
(header) => header.enabled && isEmptyAuthHeader(header),
);
if (hasEmptyAuthHeader) {
toast({
title: "Invalid Authorization Header",
description:
"Authorization header is enabled but empty. Please add a token or disable the header.",
variant: "destructive",
});
}
const needsOAuthToken = !finalHeaders.some(
(header) =>
header.enabled &&
header.name.trim().toLowerCase() === "authorization",
);
if (needsOAuthToken) {
const oauthToken = (await serverAuthProvider.tokens())?.access_token;
if (oauthToken) {
// Add the OAuth token
finalHeaders = [
// Remove any existing Authorization headers with empty tokens
...finalHeaders.filter((header) => !isEmptyAuthHeader(header)),
{
name: "Authorization",
value: `Bearer ${oauthToken}`,
@ -417,115 +461,183 @@ export function useConnection({
headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames);
}
// Add proxy authentication
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const proxyHeaders: HeadersInit = {};
if (proxyAuthToken) {
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
// 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));
let serverUrl: URL;
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddress) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddress,
);
}
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
// Determine connection URL based on the connection type
if (connectionType === "direct" && transportType !== "stdio") {
// Direct connection - use the provided URL directly (not available for STDIO)
serverUrl = new URL(sseUrl);
const requestHeaders = { ...headers };
if (mcpSessionId) {
requestHeaders["mcp-session-id"] = mcpSessionId;
}
switch (transportType) {
case "sse":
requestHeaders["Accept"] = "text/event-stream";
requestHeaders["content-type"] = "application/json";
transportOptions = {
fetch: async (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
) => {
const response = await fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
};
break;
headers: requestHeaders,
});
// Capture protocol-related headers from response
captureResponseHeaders(response);
return response;
},
requestInit: {
headers: requestHeaders,
},
};
break;
case "streamable-http":
transportOptions = {
fetch: async (
url: string | URL | globalThis.Request,
init?: RequestInit,
) => {
requestHeaders["Accept"] =
"text/event-stream, application/json";
requestHeaders["Content-Type"] = "application/json";
const response = await fetch(url, {
headers: requestHeaders,
...init,
});
// Capture protocol-related headers from response
captureResponseHeaders(response);
return response;
},
requestInit: {
headers: requestHeaders,
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
}
} else {
// Proxy connection (default behavior)
// Add proxy authentication headers for proxy connections only
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
getMCPProxyAuthToken(config);
const proxyHeaders: HeadersInit = {};
if (proxyAuthToken) {
proxyHeaders[proxyAuthTokenHeader] = `Bearer ${proxyAuthToken}`;
}
case "sse": {
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
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));
const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddressSSE) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddressSSE,
);
const proxyFullAddress = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddress) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddress,
);
}
transportOptions = {
authProvider: serverAuthProvider,
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
};
break;
}
transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
};
break;
}
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
case "sse": {
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/sse`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
const proxyFullAddressSSE = config.MCP_PROXY_FULL_ADDRESS
.value as string;
if (proxyFullAddressSSE) {
mcpProxyServerUrl.searchParams.append(
"proxyFullAddress",
proxyFullAddressSSE,
);
}
transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
};
break;
}
case "streamable-http":
mcpProxyServerUrl = new URL(`${getMCPProxyAddress(config)}/mcp`);
mcpProxyServerUrl.searchParams.append("url", sseUrl);
transportOptions = {
eventSourceInit: {
fetch: (
url: string | URL | globalThis.Request,
init?: RequestInit,
) =>
fetch(url, {
...init,
headers: { ...headers, ...proxyHeaders },
}),
},
requestInit: {
headers: { ...headers, ...proxyHeaders },
},
// TODO these should be configurable...
reconnectionOptions: {
maxReconnectionDelay: 30000,
initialReconnectionDelay: 1000,
reconnectionDelayGrowFactor: 1.5,
maxRetries: 2,
},
};
break;
}
serverUrl = mcpProxyServerUrl as URL;
serverUrl.searchParams.append("transportType", transportType);
}
(mcpProxyServerUrl as URL).searchParams.append(
"transportType",
transportType,
);
if (onNotification) {
[
@ -551,14 +663,11 @@ export function useConnection({
try {
const transport =
transportType === "streamable-http"
? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, {
? new StreamableHTTPClientTransport(serverUrl, {
sessionId: undefined,
...transportOptions,
})
: new SSEClientTransport(
mcpProxyServerUrl as URL,
transportOptions,
);
: new SSEClientTransport(serverUrl, transportOptions);
await client.connect(transport as Transport);
@ -575,7 +684,9 @@ export function useConnection({
});
} catch (error) {
console.error(
`Failed to connect to MCP Server via the MCP Inspector Proxy: ${mcpProxyServerUrl}:`,
connectionType === "direct"
? `Failed to connect directly to MCP Server at: ${serverUrl}:`
: `Failed to connect to MCP Server via the MCP Inspector Proxy: ${serverUrl}:`,
error,
);
@ -674,6 +785,8 @@ export function useConnection({
setConnectionStatus("disconnected");
setCompletionsSupported(false);
setServerCapabilities(null);
setMcpSessionId(null);
setMcpProtocolVersion(null);
};
const clearRequestHistory = () => {

View file

@ -0,0 +1,208 @@
import { cleanParams } from "../paramUtils";
import type { JsonSchemaType } from "../jsonUtils";
describe("cleanParams", () => {
it("should preserve required fields even when undefined", () => {
const schema: JsonSchemaType = {
type: "object",
required: ["requiredString", "requiredNumber"],
properties: {
requiredString: { type: "string" },
requiredNumber: { type: "number" },
optionalString: { type: "string" },
optionalNumber: { type: "number" },
},
};
const params = {
requiredString: undefined,
requiredNumber: undefined,
optionalString: undefined,
optionalNumber: undefined,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
requiredString: undefined,
requiredNumber: undefined,
// optionalString and optionalNumber should be omitted
});
});
it("should omit optional fields with empty strings", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalString: { type: "string" },
optionalNumber: { type: "number" },
},
};
const params = {
optionalString: "",
optionalNumber: "",
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({});
});
it("should omit optional fields with undefined values", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalString: { type: "string" },
optionalNumber: { type: "number" },
},
};
const params = {
optionalString: undefined,
optionalNumber: undefined,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({});
});
it("should omit optional fields with null values", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalString: { type: "string" },
optionalNumber: { type: "number" },
},
};
const params = {
optionalString: null,
optionalNumber: null,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({});
});
it("should preserve optional fields with meaningful values", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalString: { type: "string" },
optionalNumber: { type: "number" },
optionalBoolean: { type: "boolean" },
},
};
const params = {
optionalString: "hello",
optionalNumber: 42,
optionalBoolean: false, // false is a meaningful value
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
optionalString: "hello",
optionalNumber: 42,
optionalBoolean: false,
});
});
it("should handle mixed required and optional fields", () => {
const schema: JsonSchemaType = {
type: "object",
required: ["requiredField"],
properties: {
requiredField: { type: "string" },
optionalWithValue: { type: "string" },
optionalEmpty: { type: "string" },
optionalUndefined: { type: "number" },
},
};
const params = {
requiredField: "",
optionalWithValue: "test",
optionalEmpty: "",
optionalUndefined: undefined,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
requiredField: "",
optionalWithValue: "test",
});
});
it("should handle schema without required array", () => {
const schema: JsonSchemaType = {
type: "object",
properties: {
field1: { type: "string" },
field2: { type: "number" },
},
};
const params = {
field1: "",
field2: undefined,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({});
});
it("should preserve zero values for numbers", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalNumber: { type: "number" },
},
};
const params = {
optionalNumber: 0,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
optionalNumber: 0,
});
});
it("should handle the new undefined-first approach (no empty strings)", () => {
const schema: JsonSchemaType = {
type: "object",
required: ["requiredField"],
properties: {
requiredField: { type: "string" },
optionalField: { type: "string" },
},
};
// New behavior: cleared fields are undefined, never empty strings
const params = {
requiredField: undefined, // cleared required field
optionalField: undefined, // cleared optional field
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
requiredField: undefined, // required field preserved as undefined
// optionalField omitted entirely
});
});
});

Some files were not shown because too many files have changed in this diff Show more