Merge remote-tracking branch 'origin2/main' into meta_properies_in_tools

This commit is contained in:
lorenzo.neumann 2025-10-24 15:04:38 +02:00
commit caa1a2e1f5
21 changed files with 1559 additions and 776 deletions

View file

@ -143,6 +143,10 @@ const App = () => {
return localStorage.getItem("lastOauthScope") || "";
});
const [oauthClientSecret, setOauthClientSecret] = useState<string>(() => {
return localStorage.getItem("lastOauthClientSecret") || "";
});
// Custom headers state with migration from legacy auth
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
const savedHeaders = localStorage.getItem("lastCustomHeaders");
@ -282,6 +286,7 @@ const App = () => {
env,
customHeaders,
oauthClientId,
oauthClientSecret,
oauthScope,
config,
connectionType,
@ -415,6 +420,10 @@ const App = () => {
localStorage.setItem("lastOauthScope", oauthScope);
}, [oauthScope]);
useEffect(() => {
localStorage.setItem("lastOauthClientSecret", oauthClientSecret);
}, [oauthClientSecret]);
useEffect(() => {
saveInspectorConfig(CONFIG_LOCAL_STORAGE_KEY, config);
}, [config]);
@ -931,6 +940,8 @@ const App = () => {
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
setOauthClientSecret={setOauthClientSecret}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}

View file

@ -1,4 +1,5 @@
import { useState, memo, useMemo, useCallback } from "react";
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import type React from "react";
import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
@ -51,7 +52,7 @@ const JsonView = memo(
variant: "destructive",
});
}
}, [toast, normalizedData]);
}, [toast, normalizedData, setCopied]);
return (
<div className={clsx("p-4 border rounded relative", className)}>
@ -101,6 +102,7 @@ const JsonNode = memo(
initialExpandDepth,
isError = false,
}: JsonNodeProps) => {
const { toast } = useToast();
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const [typeStyleMap] = useState<Record<string, string>>({
number: "text-blue-600",
@ -113,6 +115,52 @@ const JsonNode = memo(
});
const dataType = getDataType(data);
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => setCopied(false), 500);
}
return () => {
if (timeoutId) clearTimeout(timeoutId);
};
}, [copied]);
const handleCopyValue = useCallback(
(value: JsonValue) => {
try {
let text: string;
const valueType = getDataType(value);
switch (valueType) {
case "string":
text = value as unknown as string;
break;
case "number":
case "boolean":
text = String(value);
break;
case "null":
text = "null";
break;
case "undefined":
text = "undefined";
break;
default:
text = JSON.stringify(value);
}
navigator.clipboard.writeText(text);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error coping result into the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
},
[toast],
);
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
@ -206,7 +254,7 @@ const JsonNode = memo(
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
<div className="flex mr-1 rounded hover:bg-gray-800/20 group items-start">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
@ -220,12 +268,28 @@ const JsonNode = memo(
>
"{value}"
</pre>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(value as unknown as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
<div className="flex mr-1 rounded group hover:bg-gray-800/20 items-start">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
@ -241,6 +305,22 @@ const JsonNode = memo(
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</pre>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(value as unknown as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
};
@ -253,7 +333,7 @@ const JsonNode = memo(
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20 group">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
@ -262,6 +342,22 @@ const JsonNode = memo(
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
<Button
variant="ghost"
className="ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
handleCopyValue(data as JsonValue);
}}
aria-label={name ? `Copy value of ${name}` : "Copy value"}
title={name ? `Copy value of ${name}` : "Copy value"}
>
{copied ? (
<CheckCheck className="size-4 dark:text-green-700 text-green-600" />
) : (
<Copy className="size-4 text-foreground" />
)}
</Button>
</div>
);
}

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);
@ -123,7 +131,9 @@ const PromptsTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedPrompt ? (
<div className="space-y-4">
@ -143,6 +153,7 @@ const PromptsTab = ({
onInputChange={(value) =>
handleInputChange(arg.name, value)
}
onFocus={() => handleFocus(arg.name)}
options={completions[arg.name] || []}
/>

View file

@ -225,7 +225,9 @@ const ResourcesTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
) : selectedResource ? (
<JsonView

View file

@ -56,7 +56,7 @@ const SamplingRequest = ({
},
role: {
type: "string",
default: "endTurn",
default: "assistant",
description: "Role of the model",
},
content: {

View file

@ -58,6 +58,8 @@ interface SidebarProps {
setCustomHeaders: (headers: CustomHeadersType) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthClientSecret: string;
setOauthClientSecret: (secret: string) => void;
oauthScope: string;
setOauthScope: (scope: string) => void;
onConnect: () => void;
@ -87,6 +89,8 @@ const Sidebar = ({
setCustomHeaders,
oauthClientId,
setOauthClientId,
oauthClientSecret,
setOauthClientSecret,
oauthScope,
setOauthScope,
onConnect,
@ -104,6 +108,7 @@ const Sidebar = ({
const [showAuthConfig, setShowAuthConfig] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [shownEnvVars, setShownEnvVars] = useState<Set<string>>(new Set());
const [showClientSecret, setShowClientSecret] = useState(false);
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
@ -555,6 +560,38 @@ const Sidebar = ({
data-testid="oauth-client-id-input"
className="font-mono"
/>
<label className="text-sm font-medium">
Client Secret
</label>
<div className="flex gap-2">
<Input
type={showClientSecret ? "text" : "password"}
placeholder="Client Secret (optional)"
onChange={(e) => setOauthClientSecret(e.target.value)}
value={oauthClientSecret}
data-testid="oauth-client-secret-input"
className="font-mono"
/>
<Button
variant="outline"
size="icon"
className="h-9 w-9 p-0 shrink-0"
onClick={() => setShowClientSecret(!showClientSecret)}
aria-label={
showClientSecret ? "Hide secret" : "Show secret"
}
aria-pressed={showClientSecret}
title={
showClientSecret ? "Hide secret" : "Show secret"
}
>
{showClientSecret ? (
<Eye className="h-4 w-4" aria-hidden="true" />
) : (
<EyeOff className="h-4 w-4" aria-hidden="true" />
)}
</Button>
</div>
<label className="text-sm font-medium">
Redirect URL
</label>

View file

@ -5,6 +5,13 @@ 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm";
import type { JsonValue, JsonSchemaType } from "@/utils/jsonUtils";
import {
@ -121,7 +128,7 @@ const ToolsTab = ({
renderItem={(tool) => (
<div className="flex flex-col items-start">
<span className="flex-1">{tool.name}</span>
<span className="text-sm text-gray-500 text-left">
<span className="text-sm text-gray-500 text-left line-clamp-3">
{tool.description}
</span>
</div>
@ -144,10 +151,12 @@ const ToolsTab = ({
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertDescription className="break-all">
{error}
</AlertDescription>
</Alert>
)}
<p className="text-sm text-gray-600 dark:text-gray-400">
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap max-h-48 overflow-y-auto">
{selectedTool.description}
</p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map(
@ -158,148 +167,225 @@ const ToolsTab = ({
const required = isPropertyRequired(key, inputSchema);
return (
<div key={key}>
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
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,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type="number"
id={key}
name={key}
placeholder={prop.description}
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 - try to convert to number, but store input either way
const num = Number(value);
if (!isNaN(num)) {
<div className="flex justify-between">
<Label
htmlFor={key}
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
{key}
{required && (
<span className="text-red-500 ml-1">*</span>
)}
</Label>
{prop.nullable ? (
<div className="flex items-center space-x-2">
<Checkbox
id={key}
name={key}
checked={params[key] === null}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: num,
[key]: checked
? null
: prop.default !== null
? prop.default
: prop.type === "boolean"
? false
: prop.type === "string"
? ""
: prop.type === "number" ||
prop.type === "integer"
? undefined
: undefined,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
null
</label>
</div>
) : null}
</div>
<div
role="toolinputwrapper"
className={`${prop.nullable && params[key] === null ? "pointer-events-none opacity-50" : ""}`}
>
{prop.type === "boolean" ? (
<div className="flex items-center space-x-2 mt-2">
<Checkbox
id={key}
name={key}
checked={!!params[key]}
onCheckedChange={(checked: boolean) =>
setParams({
...params,
[key]: checked,
})
}
/>
<label
htmlFor={key}
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{prop.description || "Toggle this option"}
</label>
</div>
) : prop.type === "string" && prop.enum ? (
<Select
value={
params[key] === undefined
? ""
: String(params[key])
}
onValueChange={(value) => {
if (value === "") {
setParams({
...params,
[key]: undefined,
});
} else {
// Store invalid input as string - let server validate
setParams({
...params,
[key]: value,
});
}
}}
>
<SelectTrigger id={key} className="mt-1">
<SelectValue
placeholder={
prop.description || "Select an option"
}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
) : prop.type === "string" ? (
<Textarea
id={key}
name={key}
placeholder={prop.description}
value={
params[key] === undefined
? ""
: String(params[key])
}
}}
className="mt-1"
/>
) : (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
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,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
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"
/>
</div>
)}
) : prop.type === "object" ||
prop.type === "array" ? (
<div className="mt-1">
<DynamicJsonForm
ref={(ref) => (formRefs.current[key] = ref)}
schema={{
type: prop.type,
properties: prop.properties,
description: prop.description,
items: prop.items,
}}
value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
) : prop.type === "number" ||
prop.type === "integer" ? (
<Input
type="number"
id={key}
name={key}
placeholder={prop.description}
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 - 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,
description: prop.description,
items: prop.items,
}}
value={params[key] as JsonValue}
onChange={(newValue: JsonValue) => {
setParams({
...params,
[key]: newValue,
});
// Check validation after a short delay to allow form to update
setTimeout(checkValidationErrors, 100);
}}
/>
</div>
)}
</div>
</div>
);
},

View file

@ -44,6 +44,8 @@ describe("Sidebar", () => {
setSseUrl: jest.fn(),
oauthClientId: "",
setOauthClientId: jest.fn(),
oauthClientSecret: "",
setOauthClientSecret: jest.fn(),
oauthScope: "",
setOauthScope: jest.fn(),
env: {},
@ -911,7 +913,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 5000,
is_session_item: false,
},
@ -986,7 +989,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 0,
is_session_item: false,
},
@ -1033,7 +1037,8 @@ describe("Sidebar", () => {
expect.objectContaining({
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 3000,
is_session_item: false,
},

View file

@ -44,6 +44,16 @@ describe("ToolsTab", () => {
},
},
},
{
name: "tool4",
description: "Tool with nullable field",
inputSchema: {
type: "object" as const,
properties: {
num: { type: ["number", "null"] as const },
},
},
},
];
const defaultProps = {
@ -143,6 +153,135 @@ describe("ToolsTab", () => {
);
});
it("should allow specifying null value", async () => {
const mockCallTool = jest.fn();
const toolWithNullableField = mockTools[3];
renderToolsTab({
tools: [toolWithNullableField],
selectedTool: toolWithNullableField,
callTool: mockCallTool,
});
const nullToggleButton = screen.getByRole("checkbox", { name: /null/i });
expect(nullToggleButton).toBeInTheDocument();
await act(async () => {
fireEvent.click(nullToggleButton);
});
expect(screen.getByRole("toolinputwrapper").classList).toContain(
"pointer-events-none",
);
const runButton = screen.getByRole("button", { name: /run tool/i });
await act(async () => {
fireEvent.click(runButton);
});
// Tool should have been called with null value
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableField.name, {
num: null,
});
});
it("should support tri-state nullable boolean (null -> false -> true -> null)", async () => {
const mockCallTool = jest.fn();
const toolWithNullableBoolean: Tool = {
name: "testTool",
description: "Tool with nullable boolean",
inputSchema: {
type: "object" as const,
properties: {
optionalBoolean: {
type: ["boolean", "null"] as const,
default: null,
},
},
},
};
renderToolsTab({
tools: [toolWithNullableBoolean],
selectedTool: toolWithNullableBoolean,
callTool: mockCallTool,
});
const nullCheckbox = screen.getByRole("checkbox", { name: /null/i });
const runButton = screen.getByRole("button", { name: /run tool/i });
// State 1: Initial state should be null (input disabled)
const wrapper = screen.getByRole("toolinputwrapper");
expect(wrapper.classList).toContain("pointer-events-none");
expect(wrapper.classList).toContain("opacity-50");
// Verify tool is called with null initially
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenCalledWith(toolWithNullableBoolean.name, {
optionalBoolean: null,
});
// State 2: Uncheck null checkbox -> should set value to false and enable input
await act(async () => {
fireEvent.click(nullCheckbox);
});
expect(wrapper.classList).not.toContain("pointer-events-none");
// Clear previous calls to make assertions clearer
mockCallTool.mockClear();
// Verify tool can be called with false
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: false,
},
);
// State 3: Check boolean checkbox -> should set value to true
// Find the boolean checkbox within the input wrapper (to avoid ID conflict with null checkbox)
const booleanCheckbox = within(wrapper).getByRole("checkbox");
mockCallTool.mockClear();
await act(async () => {
fireEvent.click(booleanCheckbox);
});
// Verify tool can be called with true
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: true,
},
);
// State 4: Check null checkbox again -> should set value back to null and disable input
await act(async () => {
fireEvent.click(nullCheckbox);
});
expect(wrapper.classList).toContain("pointer-events-none");
// Verify tool can be called with null again
await act(async () => {
fireEvent.click(runButton);
});
expect(mockCallTool).toHaveBeenLastCalledWith(
toolWithNullableBoolean.name,
{
optionalBoolean: null,
},
);
});
it("should disable button and change text while tool is running", async () => {
// Create a promise that we can resolve later
let resolvePromise: ((value: unknown) => void) | undefined;
@ -684,6 +823,64 @@ describe("ToolsTab", () => {
});
});
describe("Enum Parameters", () => {
const toolWithEnumParam: Tool = {
name: "enumTool",
description: "Tool with enum parameter",
inputSchema: {
type: "object" as const,
properties: {
format: {
type: "string" as const,
enum: ["json", "xml", "csv", "yaml"],
description: "Output format",
},
},
},
};
beforeEach(() => {
// Mock scrollIntoView for Radix UI Select
Element.prototype.scrollIntoView = jest.fn();
});
it("should render enum parameter as dropdown", () => {
renderToolsTab({
tools: [toolWithEnumParam],
selectedTool: toolWithEnumParam,
});
// Should render a select button instead of textarea
const selectTrigger = screen.getByRole("combobox", { name: /format/i });
expect(selectTrigger).toBeInTheDocument();
});
it("should render non-enum string parameter as textarea", () => {
const toolWithStringParam: Tool = {
name: "stringTool",
description: "Tool with regular string parameter",
inputSchema: {
type: "object" as const,
properties: {
text: {
type: "string" as const,
description: "Some text input",
},
},
},
};
renderToolsTab({
tools: [toolWithStringParam],
selectedTool: toolWithStringParam,
});
// Should render textarea, not select
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
});
describe("JSON Validation Integration", () => {
const toolWithJsonParams: Tool = {
name: "jsonTool",

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

@ -121,10 +121,16 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider {
return window.location.origin + "/oauth/callback/debug";
}
get redirect_uris() {
// Normally register both redirect URIs to support both normal and debug flows
// In debug subclass, redirectUrl may be the same as debugRedirectUrl, so remove duplicates
// See: https://github.com/modelcontextprotocol/inspector/issues/825
return [...new Set([this.redirectUrl, this.debugRedirectUrl])];
}
get clientMetadata(): OAuthClientMetadata {
// Register both redirect URIs to support both normal and debug flows
return {
redirect_uris: [this.redirectUrl, this.debugRedirectUrl],
redirect_uris: this.redirect_uris,
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],

View file

@ -14,7 +14,8 @@ export type ConfigItem = {
*/
export type InspectorConfig = {
/**
* Maximum time in milliseconds to wait for a response from the MCP server before timing out.
* Client-side timeout in milliseconds. The Inspector will cancel the request if no response
* is received within this time. Note: This is independent of any server-side timeouts.
*/
MCP_SERVER_REQUEST_TIMEOUT: ConfigItem;

View file

@ -43,8 +43,9 @@ export const DEFAULT_MCP_PROXY_LISTEN_PORT = "6277";
export const DEFAULT_INSPECTOR_CONFIG: InspectorConfig = {
MCP_SERVER_REQUEST_TIMEOUT: {
label: "Request Timeout",
description: "Timeout for requests to the MCP server (ms)",
value: 10000,
description:
"Client-side timeout (ms) - Inspector will cancel requests after this time",
value: 300000, // 5 minutes - increased to support elicitation and other long-running tools
is_session_item: false,
},
MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS: {

View file

@ -67,6 +67,7 @@ interface UseConnectionOptions {
// Custom headers support
customHeaders?: CustomHeaders;
oauthClientId?: string;
oauthClientSecret?: string;
oauthScope?: string;
config: InspectorConfig;
connectionType?: "direct" | "proxy";
@ -90,6 +91,7 @@ export function useConnection({
env,
customHeaders,
oauthClientId,
oauthClientSecret,
oauthScope,
config,
connectionType = "proxy",
@ -127,12 +129,20 @@ export function useConnection({
return;
}
const clientInformation: { client_id: string; client_secret?: string } = {
client_id: oauthClientId,
};
if (oauthClientSecret) {
clientInformation.client_secret = oauthClientSecret;
}
saveClientInformationToSessionStorage({
serverUrl: sseUrl,
clientInformation: { client_id: oauthClientId },
clientInformation,
isPreregistered: true,
});
}, [oauthClientId, sseUrl]);
}, [oauthClientId, oauthClientSecret, sseUrl]);
const pushHistory = (request: object, response?: object) => {
setRequestHistory((prev) => [

View file

@ -21,7 +21,7 @@ function useCopy({ timeout = 500 }: UseCopyProps = {}) {
clearTimeout(timeoutId);
}
};
}, [copied]);
}, [copied, timeout]);
return { copied, setCopied };
}

View file

@ -205,4 +205,112 @@ describe("cleanParams", () => {
// optionalField omitted entirely
});
});
it("should preserve null values when field has default: null", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
optionalFieldWithNullDefault: { type: "string", default: null },
optionalFieldWithoutDefault: { type: "string" },
},
};
const params = {
optionalFieldWithNullDefault: null,
optionalFieldWithoutDefault: null,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
optionalFieldWithNullDefault: null, // preserved because default: null
// optionalFieldWithoutDefault omitted
});
});
it("should preserve default values that match current value", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
fieldWithDefaultString: { type: "string", default: "defaultValue" },
fieldWithDefaultNumber: { type: "number", default: 42 },
fieldWithDefaultNull: { type: "string", default: null },
fieldWithDefaultBoolean: { type: "boolean", default: false },
},
};
const params = {
fieldWithDefaultString: "defaultValue",
fieldWithDefaultNumber: 42,
fieldWithDefaultNull: null,
fieldWithDefaultBoolean: false,
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
fieldWithDefaultString: "defaultValue",
fieldWithDefaultNumber: 42,
fieldWithDefaultNull: null,
fieldWithDefaultBoolean: false,
});
});
it("should omit values that do not match their default", () => {
const schema: JsonSchemaType = {
type: "object",
required: [],
properties: {
fieldWithDefault: { type: "string", default: "defaultValue" },
},
};
const params = {
fieldWithDefault: null, // doesn't match default
};
const cleaned = cleanParams(params, schema);
expect(cleaned).toEqual({
// fieldWithDefault omitted because value (null) doesn't match default ("defaultValue")
});
});
it("should fix regression from issue #846 - tools with multiple null defaults", () => {
// Reproduces the exact scenario from https://github.com/modelcontextprotocol/inspector/issues/846
// In v0.17.0, the cleanParams function would remove all null values,
// breaking tools that have parameters with explicit default: null
const schema: JsonSchemaType = {
type: "object",
required: ["requiredString"],
properties: {
optionalString: { type: ["string", "null"], default: null },
optionalNumber: { type: ["number", "null"], default: null },
optionalBoolean: { type: ["boolean", "null"], default: null },
requiredString: { type: "string" },
},
};
// When a user opens the tool in Inspector, fields initialize with their defaults
const params = {
optionalString: null, // initialized to default
optionalNumber: null, // initialized to default
optionalBoolean: null, // initialized to default
requiredString: "test",
};
const cleaned = cleanParams(params, schema);
// In v0.16, null defaults were preserved (working behavior)
// In v0.17.0, they were removed (regression)
// This fix restores the v0.16 behavior
expect(cleaned).toEqual({
optionalString: null,
optionalNumber: null,
optionalBoolean: null,
requiredString: "test",
});
});
});

View file

@ -188,6 +188,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("boolean");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional boolean parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes array type with string and null to string type", () => {
@ -200,6 +201,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("string");
expect(normalized.description).toBe("Optional string parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes array type with boolean and null to boolean type", () => {
@ -212,6 +214,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("boolean");
expect(normalized.description).toBe("Optional boolean parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes anyOf with number and null to number type", () => {
@ -225,6 +228,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("number");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional number parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes anyOf with integer and null to integer type", () => {
@ -238,6 +242,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("integer");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.description).toBe("Optional integer parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes array type with number and null to number type", () => {
@ -250,6 +255,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("number");
expect(normalized.description).toBe("Optional number parameter");
expect(normalized.nullable).toBeTruthy();
});
test("normalizes array type with integer and null to integer type", () => {
@ -262,6 +268,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("integer");
expect(normalized.description).toBe("Optional integer parameter");
expect(normalized.nullable).toBeTruthy();
});
test("handles anyOf with reversed order (null first)", () => {
@ -273,6 +280,7 @@ describe("normalizeUnionType", () => {
expect(normalized.type).toBe("string");
expect(normalized.anyOf).toBeUndefined();
expect(normalized.nullable).toBeTruthy();
});
test("leaves non-union schemas unchanged", () => {
@ -294,6 +302,7 @@ describe("normalizeUnionType", () => {
const normalized = normalizeUnionType(schema);
expect(normalized).toEqual(schema);
expect(normalized.nullable).toBeFalsy();
});
test("leaves anyOf with more than two types unchanged", () => {
@ -343,6 +352,7 @@ describe("normalizeUnionType", () => {
expect(normalized.minLength).toBe(1);
expect(normalized.maxLength).toBe(100);
expect(normalized.pattern).toBe("^[a-z]+$");
expect(normalized.nullable).toBeTruthy();
});
});

View file

@ -41,6 +41,7 @@ export type JsonSchemaType = {
maximum?: number;
minLength?: number;
maxLength?: number;
nullable?: boolean;
pattern?: string;
format?: string;
enum?: string[];

View file

@ -2,7 +2,7 @@ import type { JsonSchemaType } from "./jsonUtils";
/**
* Cleans parameters by removing undefined, null, and empty string values for optional fields
* while preserving all values for required fields.
* while preserving all values for required fields and fields with explicit default values.
*
* @param params - The parameters object to clean
* @param schema - The JSON schema defining which fields are required
@ -14,13 +14,23 @@ export function cleanParams(
): Record<string, unknown> {
const cleaned: Record<string, unknown> = {};
const required = schema.required || [];
const properties = schema.properties || {};
for (const [key, value] of Object.entries(params)) {
const isFieldRequired = required.includes(key);
const fieldSchema = properties[key] as JsonSchemaType | undefined;
// Check if the field has an explicit default value
const hasDefault = fieldSchema && "default" in fieldSchema;
const defaultValue = hasDefault ? fieldSchema.default : undefined;
if (isFieldRequired) {
// Required fields: always include, even if empty string or falsy
cleaned[key] = value;
} else if (hasDefault && value === defaultValue) {
// Field has a default value and current value matches it - preserve it
// This is important for cases like default: null
cleaned[key] = value;
} else {
// Optional fields: only include if they have meaningful values
if (value !== undefined && value !== "" && value !== null) {

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