Merge branch 'main' into tool-search
This commit is contained in:
commit
e0071a9889
12 changed files with 810 additions and 106 deletions
|
|
@ -33,6 +33,7 @@
|
||||||
"@radix-ui/react-popover": "^1.1.3",
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -69,6 +70,7 @@
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ import ElicitationTab, {
|
||||||
PendingElicitationRequest,
|
PendingElicitationRequest,
|
||||||
ElicitationResponse,
|
ElicitationResponse,
|
||||||
} from "./components/ElicitationTab";
|
} from "./components/ElicitationTab";
|
||||||
|
import {
|
||||||
|
CustomHeaders,
|
||||||
|
migrateFromLegacyAuth,
|
||||||
|
} from "./lib/types/customHeaders";
|
||||||
|
|
||||||
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
|
||||||
|
|
||||||
|
|
@ -127,6 +131,39 @@ const App = () => {
|
||||||
return localStorage.getItem("lastOauthScope") || "";
|
return localStorage.getItem("lastOauthScope") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Custom headers state with migration from legacy auth
|
||||||
|
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
|
||||||
|
const savedHeaders = localStorage.getItem("lastCustomHeaders");
|
||||||
|
if (savedHeaders) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(savedHeaders);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Fall back to migration if JSON parsing fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate from legacy auth if available
|
||||||
|
const legacyToken = localStorage.getItem("lastBearerToken") || "";
|
||||||
|
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
|
||||||
|
|
||||||
|
if (legacyToken) {
|
||||||
|
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Authorization: Bearer as the most common case
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer ",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
|
|
@ -213,8 +250,7 @@ const App = () => {
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
headerName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
|
|
@ -303,13 +339,38 @@ const App = () => {
|
||||||
}, [transportType]);
|
}, [transportType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
if (bearerToken) {
|
||||||
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastBearerToken");
|
||||||
|
}
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastHeaderName", headerName);
|
if (headerName) {
|
||||||
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("lastHeaderName");
|
||||||
|
}
|
||||||
}, [headerName]);
|
}, [headerName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
|
||||||
|
}, [customHeaders]);
|
||||||
|
|
||||||
|
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (customHeaders.length === 0 && (bearerToken || headerName)) {
|
||||||
|
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
|
||||||
|
if (migratedHeaders.length > 0) {
|
||||||
|
setCustomHeaders(migratedHeaders);
|
||||||
|
// Clear legacy auth after migration
|
||||||
|
setBearerToken("");
|
||||||
|
setHeaderName("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("lastOauthClientId", oauthClientId);
|
localStorage.setItem("lastOauthClientId", oauthClientId);
|
||||||
}, [oauthClientId]);
|
}, [oauthClientId]);
|
||||||
|
|
@ -810,10 +871,8 @@ const App = () => {
|
||||||
setEnv={setEnv}
|
setEnv={setEnv}
|
||||||
config={config}
|
config={config}
|
||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
customHeaders={customHeaders}
|
||||||
setBearerToken={setBearerToken}
|
setCustomHeaders={setCustomHeaders}
|
||||||
headerName={headerName}
|
|
||||||
setHeaderName={setHeaderName}
|
|
||||||
oauthClientId={oauthClientId}
|
oauthClientId={oauthClientId}
|
||||||
setOauthClientId={setOauthClientId}
|
setOauthClientId={setOauthClientId}
|
||||||
oauthScope={oauthScope}
|
oauthScope={oauthScope}
|
||||||
|
|
|
||||||
241
client/src/components/CustomHeaders.tsx
Normal file
241
client/src/components/CustomHeaders.tsx
Normal 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;
|
||||||
|
|
@ -37,6 +37,8 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import CustomHeaders from "./CustomHeaders";
|
||||||
|
import { CustomHeaders as CustomHeadersType } from "@/lib/types/customHeaders";
|
||||||
import { useToast } from "../lib/hooks/useToast";
|
import { useToast } from "../lib/hooks/useToast";
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|
@ -51,10 +53,9 @@ interface SidebarProps {
|
||||||
setSseUrl: (url: string) => void;
|
setSseUrl: (url: string) => void;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
setEnv: (env: Record<string, string>) => void;
|
setEnv: (env: Record<string, string>) => void;
|
||||||
bearerToken: string;
|
// Custom headers support
|
||||||
setBearerToken: (token: string) => void;
|
customHeaders: CustomHeadersType;
|
||||||
headerName?: string;
|
setCustomHeaders: (headers: CustomHeadersType) => void;
|
||||||
setHeaderName?: (name: string) => void;
|
|
||||||
oauthClientId: string;
|
oauthClientId: string;
|
||||||
setOauthClientId: (id: string) => void;
|
setOauthClientId: (id: string) => void;
|
||||||
oauthScope: string;
|
oauthScope: string;
|
||||||
|
|
@ -80,10 +81,8 @@ const Sidebar = ({
|
||||||
setSseUrl,
|
setSseUrl,
|
||||||
env,
|
env,
|
||||||
setEnv,
|
setEnv,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
setBearerToken,
|
setCustomHeaders,
|
||||||
headerName,
|
|
||||||
setHeaderName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
setOauthClientId,
|
setOauthClientId,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
|
|
@ -499,38 +498,12 @@ const Sidebar = ({
|
||||||
</Button>
|
</Button>
|
||||||
{showAuthConfig && (
|
{showAuthConfig && (
|
||||||
<>
|
<>
|
||||||
{/* Bearer Token Section */}
|
{/* Custom Headers Section */}
|
||||||
<div className="space-y-2 p-3 rounded border">
|
<div className="p-3 rounded border overflow-hidden">
|
||||||
<h4 className="text-sm font-semibold flex items-center">
|
<CustomHeaders
|
||||||
API Token Authentication
|
headers={customHeaders}
|
||||||
</h4>
|
onChange={setCustomHeaders}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{transportType !== "stdio" && (
|
{transportType !== "stdio" && (
|
||||||
// OAuth Configuration
|
// OAuth Configuration
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,8 @@ describe("Sidebar", () => {
|
||||||
setOauthScope: jest.fn(),
|
setOauthScope: jest.fn(),
|
||||||
env: {},
|
env: {},
|
||||||
setEnv: jest.fn(),
|
setEnv: jest.fn(),
|
||||||
bearerToken: "",
|
customHeaders: [],
|
||||||
setBearerToken: jest.fn(),
|
setCustomHeaders: jest.fn(),
|
||||||
onConnect: jest.fn(),
|
onConnect: jest.fn(),
|
||||||
onDisconnect: jest.fn(),
|
onDisconnect: jest.fn(),
|
||||||
stdErrNotifications: [],
|
stdErrNotifications: [],
|
||||||
|
|
@ -634,117 +634,179 @@ describe("Sidebar", () => {
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should update bearer token", () => {
|
it("should update bearer token via custom headers", async () => {
|
||||||
const setBearerToken = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
bearerToken: "",
|
customHeaders: [],
|
||||||
setBearerToken,
|
setCustomHeaders,
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
// Add a new header
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
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", () => {
|
it("should update header name via custom headers", () => {
|
||||||
const setHeaderName = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
headerName: "Authorization",
|
customHeaders: [
|
||||||
setHeaderName,
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer token123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setCustomHeaders,
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
const headerNameInput = screen.getByTestId("header-name-input-0");
|
||||||
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
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", () => {
|
it("should clear bearer token via custom headers", () => {
|
||||||
const setBearerToken = jest.fn();
|
const setCustomHeaders = jest.fn();
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
setBearerToken,
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
setCustomHeaders,
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const tokenInput = screen.getByTestId("bearer-token-input");
|
const headerValueInput = screen.getByTestId("header-value-input-0");
|
||||||
fireEvent.change(tokenInput, { target: { value: "" } });
|
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({
|
const { rerender } = renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Token input should be a password field
|
// 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");
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
// Update the token
|
// Update the token
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
|
||||||
|
|
||||||
// Rerender with updated token
|
// Rerender with updated token
|
||||||
rerender(
|
rerender(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
bearerToken="new_token"
|
customHeaders={[
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer new_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
transportType="sse"
|
transportType="sse"
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Token input should still exist after update
|
// 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", () => {
|
it("should maintain token visibility state after update", () => {
|
||||||
const { rerender } = renderSidebar({
|
const { rerender } = renderSidebar({
|
||||||
bearerToken: "existing_token",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer existing_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse", // Set transport type to SSE
|
transportType: "sse", // Set transport type to SSE
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Token input should be a password field
|
// 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");
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
// Update the token
|
// Update the token
|
||||||
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
fireEvent.change(tokenInput, { target: { value: "Bearer new_token" } });
|
||||||
|
|
||||||
// Rerender with updated token
|
// Rerender with updated token
|
||||||
rerender(
|
rerender(
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{...defaultProps}
|
{...defaultProps}
|
||||||
bearerToken="new_token"
|
customHeaders={[
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer new_token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
transportType="sse"
|
transportType="sse"
|
||||||
/>
|
/>
|
||||||
</TooltipProvider>,
|
</TooltipProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Token input should still exist after update
|
// 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", () => {
|
it("should maintain header name when toggling auth section", () => {
|
||||||
renderSidebar({
|
renderSidebar({
|
||||||
headerName: "X-API-Key",
|
customHeaders: [
|
||||||
|
{
|
||||||
|
name: "X-API-Key",
|
||||||
|
value: "api-key-123",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -752,7 +814,7 @@ describe("Sidebar", () => {
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
// Verify header name is displayed
|
// 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");
|
expect(headerInput).toHaveValue("X-API-Key");
|
||||||
|
|
||||||
// Close auth section
|
// Close auth section
|
||||||
|
|
@ -763,19 +825,66 @@ describe("Sidebar", () => {
|
||||||
fireEvent.click(authButton);
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
// Verify header name is still preserved
|
// 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({
|
renderSidebar({
|
||||||
headerName: undefined,
|
customHeaders: [],
|
||||||
transportType: "sse",
|
transportType: "sse",
|
||||||
});
|
});
|
||||||
|
|
||||||
openAuthSection();
|
openAuthSection();
|
||||||
|
|
||||||
const headerInput = screen.getByTestId("header-input");
|
// Verify that the "No custom headers configured" message is shown
|
||||||
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
27
client/src/components/ui/switch.tsx
Normal file
27
client/src/components/ui/switch.tsx
Normal 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 };
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from "@modelcontextprotocol/sdk/types.js";
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||||
import { discoverScopes } from "../../auth";
|
import { discoverScopes } from "../../auth";
|
||||||
|
import { CustomHeaders } from "../../types/customHeaders";
|
||||||
|
|
||||||
// Mock fetch
|
// Mock fetch
|
||||||
global.fetch = jest.fn().mockResolvedValue({
|
global.fetch = jest.fn().mockResolvedValue({
|
||||||
|
|
@ -648,9 +649,17 @@ describe("useConnection", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves server Authorization header when proxy auth is configured", async () => {
|
test("preserves server Authorization header when proxy auth is configured", async () => {
|
||||||
|
const customHeaders: CustomHeaders = [
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer server-auth-token",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const propsWithBothAuth = {
|
const propsWithBothAuth = {
|
||||||
...defaultProps,
|
...defaultProps,
|
||||||
bearerToken: "server-auth-token",
|
customHeaders,
|
||||||
config: {
|
config: {
|
||||||
...DEFAULT_INSPECTOR_CONFIG,
|
...DEFAULT_INSPECTOR_CONFIG,
|
||||||
MCP_PROXY_AUTH_TOKEN: {
|
MCP_PROXY_AUTH_TOKEN: {
|
||||||
|
|
@ -740,6 +749,139 @@ describe("useConnection", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Custom Headers", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
// Reset the mock transport objects
|
||||||
|
mockSSETransport.url = undefined;
|
||||||
|
mockSSETransport.options = undefined;
|
||||||
|
mockStreamableHTTPTransport.url = undefined;
|
||||||
|
mockStreamableHTTPTransport.options = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends multiple custom headers correctly", async () => {
|
||||||
|
const customHeaders: CustomHeaders = [
|
||||||
|
{ name: "Authorization", value: "Bearer token123", enabled: true },
|
||||||
|
{ name: "X-Tenant-ID", value: "acme-inc", enabled: true },
|
||||||
|
{ name: "X-Environment", value: "staging", enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const propsWithCustomHeaders = {
|
||||||
|
...defaultProps,
|
||||||
|
customHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnection(propsWithCustomHeaders),
|
||||||
|
);
|
||||||
|
|
||||||
|
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?.headers).toBeDefined();
|
||||||
|
|
||||||
|
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||||
|
expect(headers).toHaveProperty("Authorization", "Bearer token123");
|
||||||
|
expect(headers).toHaveProperty("X-Tenant-ID", "acme-inc");
|
||||||
|
expect(headers).toHaveProperty("X-Environment", "staging");
|
||||||
|
expect(headers).toHaveProperty(
|
||||||
|
"x-custom-auth-headers",
|
||||||
|
JSON.stringify(["X-Tenant-ID", "X-Environment"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores disabled custom headers", async () => {
|
||||||
|
const customHeaders: CustomHeaders = [
|
||||||
|
{ name: "Authorization", value: "Bearer token123", enabled: true },
|
||||||
|
{ name: "X-Disabled", value: "should-not-appear", enabled: false },
|
||||||
|
{ name: "X-Enabled", value: "should-appear", enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const propsWithCustomHeaders = {
|
||||||
|
...defaultProps,
|
||||||
|
customHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useConnection(propsWithCustomHeaders),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||||
|
expect(headers).toHaveProperty("Authorization", "Bearer token123");
|
||||||
|
expect(headers).toHaveProperty("X-Enabled", "should-appear");
|
||||||
|
expect(headers).not.toHaveProperty("X-Disabled");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles migrated legacy auth via custom headers", async () => {
|
||||||
|
// Simulate what App.tsx would do - migrate legacy auth to custom headers
|
||||||
|
const customHeaders: CustomHeaders = [
|
||||||
|
{ name: "X-Custom-Auth", value: "legacy-token", enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const propsWithMigratedAuth = {
|
||||||
|
...defaultProps,
|
||||||
|
customHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(propsWithMigratedAuth));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||||
|
expect(headers).toHaveProperty("X-Custom-Auth", "legacy-token");
|
||||||
|
expect(headers).toHaveProperty(
|
||||||
|
"x-custom-auth-headers",
|
||||||
|
JSON.stringify(["X-Custom-Auth"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses OAuth token when no custom headers or legacy auth provided", async () => {
|
||||||
|
const propsWithoutAuth = {
|
||||||
|
...defaultProps,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(propsWithoutAuth));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||||
|
expect(headers).toHaveProperty("Authorization", "Bearer mock-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prioritizes custom headers over legacy auth", async () => {
|
||||||
|
const customHeaders: CustomHeaders = [
|
||||||
|
{ name: "Authorization", value: "Bearer custom-token", enabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const propsWithBothAuth = {
|
||||||
|
...defaultProps,
|
||||||
|
customHeaders,
|
||||||
|
bearerToken: "legacy-token",
|
||||||
|
headerName: "Authorization",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useConnection(propsWithBothAuth));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = mockSSETransport.options?.requestInit?.headers;
|
||||||
|
expect(headers).toHaveProperty("Authorization", "Bearer custom-token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("OAuth Error Handling with Scope Discovery", () => {
|
describe("OAuth Error Handling with Scope Discovery", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import {
|
||||||
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
import { getMCPServerRequestTimeout } from "@/utils/configUtils";
|
||||||
import { InspectorConfig } from "../configurationTypes";
|
import { InspectorConfig } from "../configurationTypes";
|
||||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||||
|
import { CustomHeaders } from "../types/customHeaders";
|
||||||
|
|
||||||
interface UseConnectionOptions {
|
interface UseConnectionOptions {
|
||||||
transportType: "stdio" | "sse" | "streamable-http";
|
transportType: "stdio" | "sse" | "streamable-http";
|
||||||
|
|
@ -64,8 +65,8 @@ interface UseConnectionOptions {
|
||||||
args: string;
|
args: string;
|
||||||
sseUrl: string;
|
sseUrl: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
bearerToken?: string;
|
// Custom headers support
|
||||||
headerName?: string;
|
customHeaders?: CustomHeaders;
|
||||||
oauthClientId?: string;
|
oauthClientId?: string;
|
||||||
oauthScope?: string;
|
oauthScope?: string;
|
||||||
config: InspectorConfig;
|
config: InspectorConfig;
|
||||||
|
|
@ -86,8 +87,7 @@ export function useConnection({
|
||||||
args,
|
args,
|
||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
customHeaders,
|
||||||
headerName,
|
|
||||||
oauthClientId,
|
oauthClientId,
|
||||||
oauthScope,
|
oauthScope,
|
||||||
config,
|
config,
|
||||||
|
|
@ -379,21 +379,44 @@ export function useConnection({
|
||||||
// Create an auth provider with the current server URL
|
// Create an auth provider with the current server URL
|
||||||
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
|
||||||
|
|
||||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
// Use custom headers (migration is handled in App.tsx)
|
||||||
const token =
|
let finalHeaders: CustomHeaders = customHeaders || [];
|
||||||
bearerToken || (await serverAuthProvider.tokens())?.access_token;
|
|
||||||
if (token) {
|
|
||||||
const authHeaderName = headerName || "Authorization";
|
|
||||||
|
|
||||||
// Add custom header name as a special request header to let the server know which header to pass through
|
// Add OAuth token if available and no custom headers are set
|
||||||
if (authHeaderName.toLowerCase() !== "authorization") {
|
if (finalHeaders.length === 0) {
|
||||||
headers[authHeaderName] = token;
|
const oauthToken = (await serverAuthProvider.tokens())?.access_token;
|
||||||
headers["x-custom-auth-header"] = authHeaderName;
|
if (oauthToken) {
|
||||||
} else {
|
finalHeaders = [
|
||||||
headers[authHeaderName] = `Bearer ${token}`;
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: `Bearer ${oauthToken}`,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process all enabled custom headers
|
||||||
|
const customHeaderNames: string[] = [];
|
||||||
|
finalHeaders.forEach((header) => {
|
||||||
|
if (header.enabled && header.name.trim() && header.value.trim()) {
|
||||||
|
const headerName = header.name.trim();
|
||||||
|
const headerValue = header.value.trim();
|
||||||
|
|
||||||
|
headers[headerName] = headerValue;
|
||||||
|
|
||||||
|
// Track custom header names for server processing
|
||||||
|
if (headerName.toLowerCase() !== "authorization") {
|
||||||
|
customHeaderNames.push(headerName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add custom header names as a special request header for server processing
|
||||||
|
if (customHeaderNames.length > 0) {
|
||||||
|
headers["x-custom-auth-headers"] = JSON.stringify(customHeaderNames);
|
||||||
|
}
|
||||||
|
|
||||||
// Add proxy authentication
|
// Add proxy authentication
|
||||||
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
const { token: proxyAuthToken, header: proxyAuthTokenHeader } =
|
||||||
getMCPProxyAuthToken(config);
|
getMCPProxyAuthToken(config);
|
||||||
|
|
|
||||||
64
client/src/lib/types/customHeaders.ts
Normal file
64
client/src/lib/types/customHeaders.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
export interface CustomHeader {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomHeaders = CustomHeader[];
|
||||||
|
|
||||||
|
export const createEmptyHeader = (): CustomHeader => ({
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createHeaderFromBearerToken = (
|
||||||
|
bearerToken: string,
|
||||||
|
headerName?: string,
|
||||||
|
): CustomHeader => ({
|
||||||
|
name: headerName || "Authorization",
|
||||||
|
value:
|
||||||
|
headerName?.toLowerCase() === "authorization" || !headerName
|
||||||
|
? `Bearer ${bearerToken}`
|
||||||
|
: bearerToken,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEnabledHeaders = (headers: CustomHeaders): CustomHeaders => {
|
||||||
|
return headers.filter(
|
||||||
|
(header) => header.enabled && header.name.trim() && header.value.trim(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headersToRecord = (
|
||||||
|
headers: CustomHeaders,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const enabledHeaders = getEnabledHeaders(headers);
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
|
||||||
|
enabledHeaders.forEach((header) => {
|
||||||
|
record[header.name.trim()] = header.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordToHeaders = (
|
||||||
|
record: Record<string, string>,
|
||||||
|
): CustomHeaders => {
|
||||||
|
return Object.entries(record).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migration helper for backward compatibility
|
||||||
|
export const migrateFromLegacyAuth = (
|
||||||
|
bearerToken?: string,
|
||||||
|
headerName?: string,
|
||||||
|
): CustomHeaders => {
|
||||||
|
return bearerToken
|
||||||
|
? [createHeaderFromBearerToken(bearerToken, headerName)]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
40
package-lock.json
generated
40
package-lock.json
generated
|
|
@ -80,6 +80,7 @@
|
||||||
"@radix-ui/react-popover": "^1.1.3",
|
"@radix-ui/react-popover": "^1.1.3",
|
||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.1",
|
"@radix-ui/react-tabs": "^1.1.1",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
"@radix-ui/react-toast": "^1.2.6",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -119,6 +120,7 @@
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
|
"jest-fixed-jsdom": "^0.0.9",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|
@ -2619,6 +2621,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-tabs": {
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz",
|
||||||
|
|
@ -11752,6 +11789,8 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"shell-quote": "^1.8.3",
|
||||||
|
"spawn-rx": "^5.1.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|
@ -11761,6 +11800,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.23",
|
"@types/express": "^4.17.23",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^4.17.23",
|
"@types/express": "^4.17.23",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ws": "^8.5.12",
|
"@types/ws": "^8.5.12",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.2"
|
||||||
|
|
@ -30,6 +31,8 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.18.0",
|
"@modelcontextprotocol/sdk": "^1.18.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"shell-quote": "^1.8.3",
|
||||||
|
"spawn-rx": "^5.1.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,27 @@ const getHttpHeaders = (req: express.Request): Record<string, string> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle multiple custom headers (new approach)
|
||||||
|
if (req.headers["x-custom-auth-headers"] !== undefined) {
|
||||||
|
try {
|
||||||
|
const customHeaderNames = JSON.parse(
|
||||||
|
req.headers["x-custom-auth-headers"] as string,
|
||||||
|
) as string[];
|
||||||
|
if (Array.isArray(customHeaderNames)) {
|
||||||
|
customHeaderNames.forEach((headerName) => {
|
||||||
|
const lowerCaseHeaderName = headerName.toLowerCase();
|
||||||
|
if (req.headers[lowerCaseHeaderName] !== undefined) {
|
||||||
|
const value = req.headers[lowerCaseHeaderName];
|
||||||
|
headers[headerName] = Array.isArray(value)
|
||||||
|
? value[value.length - 1]
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to parse x-custom-auth-headers:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
return headers;
|
return headers;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue