Merge pull request #272 from idosal/set-header
Enable Authentication header name configuration
This commit is contained in:
@@ -40,7 +40,7 @@ For more details on ways to use the inspector, see the [Inspector section of the
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header.
|
The inspector supports bearer token authentication for SSE connections. Enter your token in the UI when connecting to an MCP server, and it will be sent in the Authorization header. You can override the header name using the input field in the sidebar.
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ const App = () => {
|
|||||||
return localStorage.getItem("lastBearerToken") || "";
|
return localStorage.getItem("lastBearerToken") || "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [headerName, setHeaderName] = useState<string>(() => {
|
||||||
|
return localStorage.getItem("lastHeaderName") || "";
|
||||||
|
});
|
||||||
|
|
||||||
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
const [pendingSampleRequests, setPendingSampleRequests] = useState<
|
||||||
Array<
|
Array<
|
||||||
PendingRequest & {
|
PendingRequest & {
|
||||||
@@ -169,6 +173,7 @@ const App = () => {
|
|||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
|
headerName,
|
||||||
config,
|
config,
|
||||||
onNotification: (notification) => {
|
onNotification: (notification) => {
|
||||||
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
setNotifications((prev) => [...prev, notification as ServerNotification]);
|
||||||
@@ -208,6 +213,10 @@ const App = () => {
|
|||||||
localStorage.setItem("lastBearerToken", bearerToken);
|
localStorage.setItem("lastBearerToken", bearerToken);
|
||||||
}, [bearerToken]);
|
}, [bearerToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("lastHeaderName", headerName);
|
||||||
|
}, [headerName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
|
||||||
}, [config]);
|
}, [config]);
|
||||||
@@ -500,6 +509,8 @@ const App = () => {
|
|||||||
setConfig={setConfig}
|
setConfig={setConfig}
|
||||||
bearerToken={bearerToken}
|
bearerToken={bearerToken}
|
||||||
setBearerToken={setBearerToken}
|
setBearerToken={setBearerToken}
|
||||||
|
headerName={headerName}
|
||||||
|
setHeaderName={setHeaderName}
|
||||||
onConnect={connectMcpServer}
|
onConnect={connectMcpServer}
|
||||||
onDisconnect={disconnectMcpServer}
|
onDisconnect={disconnectMcpServer}
|
||||||
stdErrNotifications={stdErrNotifications}
|
stdErrNotifications={stdErrNotifications}
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ interface SidebarProps {
|
|||||||
setEnv: (env: Record<string, string>) => void;
|
setEnv: (env: Record<string, string>) => void;
|
||||||
bearerToken: string;
|
bearerToken: string;
|
||||||
setBearerToken: (token: string) => void;
|
setBearerToken: (token: string) => void;
|
||||||
|
headerName?: string;
|
||||||
|
setHeaderName?: (name: string) => void;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
onDisconnect: () => void;
|
onDisconnect: () => void;
|
||||||
stdErrNotifications: StdErrNotification[];
|
stdErrNotifications: StdErrNotification[];
|
||||||
@@ -76,6 +78,8 @@ const Sidebar = ({
|
|||||||
setEnv,
|
setEnv,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
|
headerName,
|
||||||
|
setHeaderName,
|
||||||
onConnect,
|
onConnect,
|
||||||
onDisconnect,
|
onDisconnect,
|
||||||
stdErrNotifications,
|
stdErrNotifications,
|
||||||
@@ -176,6 +180,7 @@ const Sidebar = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowBearerToken(!showBearerToken)}
|
onClick={() => setShowBearerToken(!showBearerToken)}
|
||||||
className="flex items-center w-full"
|
className="flex items-center w-full"
|
||||||
|
data-testid="auth-button"
|
||||||
aria-expanded={showBearerToken}
|
aria-expanded={showBearerToken}
|
||||||
>
|
>
|
||||||
{showBearerToken ? (
|
{showBearerToken ? (
|
||||||
@@ -187,6 +192,16 @@ const Sidebar = ({
|
|||||||
</Button>
|
</Button>
|
||||||
{showBearerToken && (
|
{showBearerToken && (
|
||||||
<div className="space-y-2">
|
<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
|
<label
|
||||||
className="text-sm font-medium"
|
className="text-sm font-medium"
|
||||||
htmlFor="bearer-token-input"
|
htmlFor="bearer-token-input"
|
||||||
@@ -198,6 +213,7 @@ const Sidebar = ({
|
|||||||
placeholder="Bearer Token"
|
placeholder="Bearer Token"
|
||||||
value={bearerToken}
|
value={bearerToken}
|
||||||
onChange={(e) => setBearerToken(e.target.value)}
|
onChange={(e) => setBearerToken(e.target.value)}
|
||||||
|
data-testid="bearer-token-input"
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
import { describe, it, beforeEach, jest } from "@jest/globals";
|
import { describe, it, beforeEach, jest } from "@jest/globals";
|
||||||
import Sidebar from "../Sidebar";
|
import Sidebar from "../Sidebar";
|
||||||
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
import { DEFAULT_INSPECTOR_CONFIG } from "@/lib/constants";
|
||||||
@@ -109,6 +110,157 @@ describe("Sidebar Environment Variables", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Authentication", () => {
|
||||||
|
const openAuthSection = () => {
|
||||||
|
const button = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(button);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should update bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("new_token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update header name", () => {
|
||||||
|
const setHeaderName = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "Authorization",
|
||||||
|
setHeaderName,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
fireEvent.change(headerInput, { target: { value: "X-Custom-Auth" } });
|
||||||
|
|
||||||
|
expect(setHeaderName).toHaveBeenCalledWith("X-Custom-Auth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear bearer token", () => {
|
||||||
|
const setBearerToken = jest.fn();
|
||||||
|
renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
setBearerToken,
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "" } });
|
||||||
|
|
||||||
|
expect(setBearerToken).toHaveBeenCalledWith("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly render bearer token input", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain token visibility state after update", () => {
|
||||||
|
const { rerender } = renderSidebar({
|
||||||
|
bearerToken: "existing_token",
|
||||||
|
transportType: "sse", // Set transport type to SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Token input should be a password field
|
||||||
|
const tokenInput = screen.getByTestId("bearer-token-input");
|
||||||
|
expect(tokenInput).toHaveProperty("type", "password");
|
||||||
|
|
||||||
|
// Update the token
|
||||||
|
fireEvent.change(tokenInput, { target: { value: "new_token" } });
|
||||||
|
|
||||||
|
// Rerender with updated token
|
||||||
|
rerender(
|
||||||
|
<TooltipProvider>
|
||||||
|
<Sidebar
|
||||||
|
{...defaultProps}
|
||||||
|
bearerToken="new_token"
|
||||||
|
transportType="sse"
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token input should still exist after update
|
||||||
|
expect(screen.getByTestId("bearer-token-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should maintain header name when toggling auth section", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: "X-API-Key",
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open auth section
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
// Verify header name is displayed
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveValue("X-API-Key");
|
||||||
|
|
||||||
|
// Close auth section
|
||||||
|
const authButton = screen.getByTestId("auth-button");
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Reopen auth section
|
||||||
|
fireEvent.click(authButton);
|
||||||
|
|
||||||
|
// Verify header name is still preserved
|
||||||
|
expect(screen.getByTestId("header-input")).toHaveValue("X-API-Key");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should display default header name when not specified", () => {
|
||||||
|
renderSidebar({
|
||||||
|
headerName: undefined,
|
||||||
|
transportType: "sse",
|
||||||
|
});
|
||||||
|
|
||||||
|
openAuthSection();
|
||||||
|
|
||||||
|
const headerInput = screen.getByTestId("header-input");
|
||||||
|
expect(headerInput).toHaveAttribute("placeholder", "Authorization");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Key Editing", () => {
|
describe("Key Editing", () => {
|
||||||
it("should maintain order when editing first key", () => {
|
it("should maintain order when editing first key", () => {
|
||||||
const setEnv = jest.fn();
|
const setEnv = jest.fn();
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface UseConnectionOptions {
|
|||||||
sseUrl: string;
|
sseUrl: string;
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
bearerToken?: string;
|
bearerToken?: string;
|
||||||
|
headerName?: string;
|
||||||
config: InspectorConfig;
|
config: InspectorConfig;
|
||||||
onNotification?: (notification: Notification) => void;
|
onNotification?: (notification: Notification) => void;
|
||||||
onStdErrNotification?: (notification: Notification) => void;
|
onStdErrNotification?: (notification: Notification) => void;
|
||||||
@@ -64,6 +65,7 @@ export function useConnection({
|
|||||||
sseUrl,
|
sseUrl,
|
||||||
env,
|
env,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
|
headerName,
|
||||||
config,
|
config,
|
||||||
onNotification,
|
onNotification,
|
||||||
onStdErrNotification,
|
onStdErrNotification,
|
||||||
@@ -293,7 +295,8 @@ export function useConnection({
|
|||||||
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
// Use manually provided bearer token if available, otherwise use OAuth tokens
|
||||||
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
const token = bearerToken || (await authProvider.tokens())?.access_token;
|
||||||
if (token) {
|
if (token) {
|
||||||
headers["Authorization"] = `Bearer ${token}`;
|
const authHeaderName = headerName || "Authorization";
|
||||||
|
headers[authHeaderName] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
const clientTransport = new SSEClientTransport(mcpProxyServerUrl, {
|
||||||
|
|||||||
Reference in New Issue
Block a user