Merge branch 'main' into fixWrongRoleValue

This commit is contained in:
Bill Lau 2025-09-17 15:42:24 +08:00 • committed by GitHub
commit 1b0d235b5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 914 additions and 151 deletions

View file

@ -0,0 +1,241 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
import {
CustomHeaders as CustomHeadersType,
CustomHeader,
createEmptyHeader,
} from "@/lib/types/customHeaders";
interface CustomHeadersProps {
headers: CustomHeadersType;
onChange: (headers: CustomHeadersType) => void;
className?: string;
}
const CustomHeaders = ({
headers,
onChange,
className,
}: CustomHeadersProps) => {
const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonValue, setJsonValue] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
const updateHeader = (
index: number,
field: keyof CustomHeader,
value: string | boolean,
) => {
const newHeaders = [...headers];
newHeaders[index] = { ...newHeaders[index], [field]: value };
onChange(newHeaders);
};
const addHeader = () => {
onChange([...headers, createEmptyHeader()]);
};
const removeHeader = (index: number) => {
const newHeaders = headers.filter((_, i) => i !== index);
onChange(newHeaders);
};
const toggleValueVisibility = (index: number) => {
const newVisible = new Set(visibleValues);
if (newVisible.has(index)) {
newVisible.delete(index);
} else {
newVisible.add(index);
}
setVisibleValues(newVisible);
};
const switchToJsonMode = () => {
const jsonObject: Record<string, string> = {};
headers.forEach((header) => {
if (header.enabled && header.name.trim() && header.value.trim()) {
jsonObject[header.name.trim()] = header.value.trim();
}
});
setJsonValue(JSON.stringify(jsonObject, null, 2));
setJsonError(null);
setIsJsonMode(true);
};
const switchToFormMode = () => {
try {
const parsed = JSON.parse(jsonValue);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
setJsonError("JSON must be an object with string key-value pairs");
return;
}
const newHeaders: CustomHeadersType = Object.entries(parsed).map(
([name, value]) => ({
name,
value: String(value),
enabled: true,
}),
);
onChange(newHeaders);
setJsonError(null);
setIsJsonMode(false);
} catch {
setJsonError("Invalid JSON format");
}
};
const handleJsonChange = (value: string) => {
setJsonValue(value);
setJsonError(null);
};
if (isJsonMode) {
return (
<div className={`space-y-3 ${className}`}>
<div className="flex justify-between items-center gap-2">
<h4 className="text-sm font-semibold flex-shrink-0">
Custom Headers (JSON)
</h4>
<Button
type="button"
variant="outline"
size="sm"
onClick={switchToFormMode}
className="flex-shrink-0"
>
Switch to Form
</Button>
</div>
<div className="space-y-2">
<Textarea
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
className="font-mono text-sm min-h-[100px] resize-none"
/>
{jsonError && <p className="text-sm text-red-600">{jsonError}</p>}
<p className="text-xs text-muted-foreground">
Enter headers as a JSON object with string key-value pairs.
</p>
</div>
</div>
);
}
return (
<div className={`space-y-3 ${className}`}>
<div className="flex justify-between items-center gap-2">
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
<div className="flex gap-1 flex-shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={switchToJsonMode}
className="text-xs px-2"
>
JSON
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={addHeader}
className="text-xs px-2"
data-testid="add-header-button"
>
<Plus className="w-3 h-3 mr-1" />
Add
</Button>
</div>
</div>
{headers.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
<p className="text-sm">No custom headers configured</p>
<p className="text-xs mt-1">Click "Add" to get started</p>
</div>
) : (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{headers.map((header, index) => (
<div
key={index}
className="flex items-start gap-2 p-2 border rounded-md"
>
<Switch
checked={header.enabled}
onCheckedChange={(enabled) =>
updateHeader(index, "enabled", enabled)
}
className="shrink-0 mt-2"
/>
<div className="flex-1 min-w-0 space-y-2">
<Input
placeholder="Header Name"
value={header.name}
onChange={(e) => updateHeader(index, "name", e.target.value)}
className="font-mono text-xs"
data-testid={`header-name-input-${index}`}
/>
<div className="relative">
<Input
placeholder="Header Value"
value={header.value}
onChange={(e) =>
updateHeader(index, "value", e.target.value)
}
type={visibleValues.has(index) ? "text" : "password"}
className="font-mono text-xs pr-8"
data-testid={`header-value-input-${index}`}
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleValueVisibility(index)}
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
>
{visibleValues.has(index) ? (
<EyeOff className="w-3 h-3" />
) : (
<Eye className="w-3 h-3" />
)}
</Button>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeHeader(index)}
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
{headers.length > 0 && (
<p className="text-xs text-muted-foreground">
Use the toggle to enable/disable headers. Only enabled headers with
both name and value will be sent.
</p>
)}
</div>
);
};
export default CustomHeaders;

View file

@ -1,10 +1,11 @@
import { useState, memo, useMemo, useCallback, useEffect } from "react";
import { useState, memo, useMemo, useCallback } from "react";
import type { JsonValue } from "@/utils/jsonUtils";
import clsx from "clsx";
import { Copy, CheckCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/lib/hooks/useToast";
import { getDataType, tryParseJson } from "@/utils/jsonUtils";
import useCopy from "@/lib/hooks/useCopy";
interface JsonViewProps {
data: unknown;
@ -25,21 +26,7 @@ const JsonView = memo(
isError = false,
}: JsonViewProps) => {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (copied) {
timeoutId = setTimeout(() => {
setCopied(false);
}, 500);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [copied]);
const { copied, setCopied } = useCopy();
const normalizedData = useMemo(() => {
return typeof data === "string"

View file

@ -37,6 +37,8 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import CustomHeaders from "./CustomHeaders";
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
import { useToast } from "../lib/hooks/useToast";
interface SidebarProps {
@ -51,10 +53,9 @@ interface SidebarProps {
setSseUrl: (url: string) => void;
env: Record<string, string>;
setEnv: (env: Record<string, string>) => void;
bearerToken: string;
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
// Custom headers support
customHeaders: CustomHeadersType;
setCustomHeaders: (headers: CustomHeadersType) => void;
oauthClientId: string;
setOauthClientId: (id: string) => void;
oauthScope: string;
@ -80,10 +81,8 @@ const Sidebar = ({
setSseUrl,
env,
setEnv,
bearerToken,
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
oauthClientId,
setOauthClientId,
oauthScope,
@ -499,38 +498,12 @@ const Sidebar = ({
</Button>
{showAuthConfig && (
<>
{/* Bearer Token Section */}
<div className="space-y-2 p-3 rounded border">
<h4 className="text-sm font-semibold flex items-center">
API Token Authentication
</h4>
<div className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Authorization"
onChange={(e) =>
setHeaderName && setHeaderName(e.target.value)
}
data-testid="header-input"
className="font-mono"
value={headerName}
/>
<label
className="text-sm font-medium"
htmlFor="bearer-token-input"
>
Bearer Token
</label>
<Input
id="bearer-token-input"
placeholder="Bearer Token"
value={bearerToken}
onChange={(e) => setBearerToken(e.target.value)}
data-testid="bearer-token-input"
className="font-mono"
type="password"
/>
</div>
{/* Custom Headers Section */}
<div className="p-3 rounded border overflow-hidden">
<CustomHeaders
headers={customHeaders}
onChange={setCustomHeaders}
/>
</div>
{transportType !== "stdio" && (
// OAuth Configuration

View file

@ -17,11 +17,20 @@ import {
ListToolsResult,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Loader2, Send, ChevronDown, ChevronUp } from "lucide-react";
import {
Loader2,
Send,
ChevronDown,
ChevronUp,
Copy,
CheckCheck,
} from "lucide-react";
import { useEffect, useState } from "react";
import ListPane from "./ListPane";
import JsonView from "./JsonView";
import ToolResults from "./ToolResults";
import { useToast } from "@/lib/hooks/useToast";
import useCopy from "@/lib/hooks/useCopy";
// Type guard to safely detect the optional _meta field without using `any`
const hasMeta = (tool: Tool): tool is Tool & { _meta: unknown } =>
@ -55,6 +64,8 @@ const ToolsTab = ({
const [isToolRunning, setIsToolRunning] = useState(false);
const [isOutputSchemaExpanded, setIsOutputSchemaExpanded] = useState(false);
const [isMetaExpanded, setIsMetaExpanded] = useState(false);
const { toast } = useToast();
const { copied, setCopied } = useCopy();
useEffect(() => {
const params = Object.entries(
@ -291,29 +302,54 @@ const ToolsTab = ({
</div>
</div>
)}
<Button
onClick={async () => {
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
<div className="flex gap-2">
<Button
onClick={async () => {
try {
setIsToolRunning(true);
await callTool(selectedTool.name, params);
} finally {
setIsToolRunning(false);
}
}}
disabled={isToolRunning}
>
{isToolRunning ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Run Tool
</>
)}
</Button>
<Button
onClick={async () => {
try {
navigator.clipboard.writeText(
JSON.stringify(params, null, 2),
);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error copying input to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
}}
>
{copied ? (
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy Input
</Button>
</div>
<ToolResults
toolResult={toolResult}
selectedTool={selectedTool}

View file

@ -48,8 +48,8 @@ describe("Sidebar", () => {
setOauthScope: jest.fn(),
env: {},
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
@ -634,117 +634,179 @@ describe("Sidebar", () => {
fireEvent.click(button);
};
it("should update bearer token", () => {
const setBearerToken = jest.fn();
it("should update bearer token via custom headers", async () => {
const setCustomHeaders = jest.fn();
renderSidebar({
bearerToken: "",
setBearerToken,
customHeaders: [],
setCustomHeaders,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "new_token" } });
// Add a new header
const addButton = screen.getByTestId("add-header-button");
fireEvent.click(addButton);
expect(setBearerToken).toHaveBeenCalledWith("new_token");
// Verify that setCustomHeaders was called to add an empty header
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "",
value: "",
enabled: true,
},
]);
});
it("should update header name", () => {
const setHeaderName = jest.fn();
it("should update header name via custom headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
headerName: "Authorization",
setHeaderName,
customHeaders: [
{
name: "Authorization",
value: "Bearer token123",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
const headerNameInput = screen.getByTestId("header-name-input-0");
fireEvent.change(headerNameInput, { target: { value: "X-Custom-Auth" } });
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "X-Custom-Auth",
value: "Bearer token123",
enabled: true,
},
]);
});
it("should clear bearer token", () => {
const setBearerToken = jest.fn();
it("should clear bearer token via custom headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
bearerToken: "existing_token",
setBearerToken,
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
const tokenInput = screen.getByTestId("bearer-token-input");
fireEvent.change(tokenInput, { target: { value: "" } });
const headerValueInput = screen.getByTestId("header-value-input-0");
fireEvent.change(headerValueInput, { target: { value: "" } });
expect(setBearerToken).toHaveBeenCalledWith("");
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "Authorization",
value: "",
enabled: true,
},
]);
});
it("should properly render bearer token input", () => {
it("should properly render header value input as password field", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
const tokenInput = screen.getByTestId("header-value-input-0");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
customHeaders={[
{
name: "Authorization",
value: "Bearer new_token",
enabled: true,
},
]}
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
});
it("should maintain token visibility state after update", () => {
const { rerender } = renderSidebar({
bearerToken: "existing_token",
customHeaders: [
{
name: "Authorization",
value: "Bearer existing_token",
enabled: true,
},
],
transportType: "sse", // Set transport type to SSE
});
openAuthSection();
// Token input should be a password field
const tokenInput = screen.getByTestId("bearer-token-input");
const tokenInput = screen.getByTestId("header-value-input-0");
expect(tokenInput).toHaveProperty("type", "password");
// Update the token
fireEvent.change(tokenInput, { target: { value: "new_token" } });
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
// Rerender with updated token
rerender(
<TooltipProvider>
<Sidebar
{...defaultProps}
bearerToken="new_token"
customHeaders={[
{
name: "Authorization",
value: "Bearer new_token",
enabled: true,
},
]}
transportType="sse"
/>
</TooltipProvider>,
);
// Token input should still exist after update
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
expect(screen.getByTestId("header-value-input-0")).toBeInTheDocument();
});
it("should maintain header name when toggling auth section", () => {
renderSidebar({
headerName: "X-API-Key",
customHeaders: [
{
name: "X-API-Key",
value: "api-key-123",
enabled: true,
},
],
transportType: "sse",
});
@ -752,7 +814,7 @@ describe("Sidebar", () => {
openAuthSection();
// Verify header name is displayed
const headerInput = screen.getByTestId("header-input");
const headerInput = screen.getByTestId("header-name-input-0");
expect(headerInput).toHaveValue("X-API-Key");
// Close auth section
@ -763,19 +825,66 @@ describe("Sidebar", () => {
fireEvent.click(authButton);
// Verify header name is still preserved
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
expect(screen.getByTestId("header-name-input-0")).toHaveValue(
"X-API-Key",
);
});
it("should display default header name when not specified", () => {
it("should display placeholder for header name when no headers exist", () => {
renderSidebar({
headerName: undefined,
customHeaders: [],
transportType: "sse",
});
openAuthSection();
const headerInput = screen.getByTestId("header-input");
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
// Verify that the "No custom headers configured" message is shown
expect(
screen.getByText("No custom headers configured"),
).toBeInTheDocument();
expect(
screen.getByText('Click "Add" to get started'),
).toBeInTheDocument();
// Verify the Add button is present
const addButton = screen.getByTestId("add-header-button");
expect(addButton).toBeInTheDocument();
});
it("should allow editing existing headers", () => {
const setCustomHeaders = jest.fn();
renderSidebar({
customHeaders: [
{
name: "Authorization",
value: "Bearer token123",
enabled: true,
},
],
setCustomHeaders,
transportType: "sse",
});
openAuthSection();
// Verify header inputs are rendered
const headerNameInput = screen.getByTestId("header-name-input-0");
const headerValueInput = screen.getByTestId("header-value-input-0");
expect(headerNameInput).toHaveValue("Authorization");
expect(headerValueInput).toHaveValue("Bearer token123");
expect(headerNameInput).toHaveAttribute("placeholder", "Header Name");
expect(headerValueInput).toHaveAttribute("placeholder", "Header Value");
// Update header name
fireEvent.change(headerNameInput, { target: { value: "X-API-Key" } });
expect(setCustomHeaders).toHaveBeenCalledWith([
{
name: "X-API-Key",
value: "Bearer token123",
enabled: true,
},
]);
});
});

View file

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };