add: support custom headers

This commit is contained in:
h z
2025-05-16 15:31:23 +01:00
parent c843005928
commit d3c0217681
9 changed files with 943 additions and 101 deletions

View File

@@ -4,7 +4,21 @@ WORKDIR /app
COPY . . COPY . .
RUN npm ci --ignore-scripts && \
RUN npm install
RUN cd client && \
npm install && \
npm run build
RUN cd server && \
npm install && \
npm run build
RUN cd cli && \
npm install && \
npm run build npm run build
FROM node:20-alpine FROM node:20-alpine
@@ -22,5 +36,4 @@ RUN npm ci --omit=dev --ignore-scripts
ENV NODE_ENV=production ENV NODE_ENV=production
CMD ["node", "client/bin/start.js"] CMD ["node", "client/bin/start.js"]

View File

@@ -51,7 +51,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^14.2.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",

View File

@@ -109,6 +109,11 @@ const App = () => {
return localStorage.getItem("lastHeaderName") || ""; return localStorage.getItem("lastHeaderName") || "";
}); });
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const saved = localStorage.getItem("lastCustomHeaders");
return saved ? JSON.parse(saved) : [];
});
const [pendingSampleRequests, setPendingSampleRequests] = useState< const [pendingSampleRequests, setPendingSampleRequests] = useState<
Array< Array<
PendingRequest & { PendingRequest & {
@@ -183,6 +188,7 @@ const App = () => {
env, env,
bearerToken, bearerToken,
headerName, headerName,
customHeaders,
config, config,
onNotification: (notification) => { onNotification: (notification) => {
setNotifications((prev) => [...prev, notification as ServerNotification]); setNotifications((prev) => [...prev, notification as ServerNotification]);
@@ -226,6 +232,10 @@ const App = () => {
localStorage.setItem("lastHeaderName", headerName); localStorage.setItem("lastHeaderName", headerName);
}, [headerName]); }, [headerName]);
useEffect(() => {
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
}, [customHeaders]);
useEffect(() => { useEffect(() => {
localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config));
}, [config]); }, [config]);
@@ -581,6 +591,8 @@ const App = () => {
setBearerToken={setBearerToken} setBearerToken={setBearerToken}
headerName={headerName} headerName={headerName}
setHeaderName={setHeaderName} setHeaderName={setHeaderName}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
onConnect={connectMcpServer} onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer} onDisconnect={disconnectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}

View File

@@ -40,7 +40,8 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useToast } from "../lib/hooks/useToast"; import { useToast } from "../lib/hooks/useToast";
interface SidebarProps { export interface SidebarProps {
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http"; transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse" | "streamable-http") => void; setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
@@ -56,6 +57,8 @@ interface SidebarProps {
setBearerToken: (token: string) => void; setBearerToken: (token: string) => void;
headerName?: string; headerName?: string;
setHeaderName?: (name: string) => void; setHeaderName?: (name: string) => void;
customHeaders: [string, string][];
setCustomHeaders: (headers: [string, string][]) => void;
onConnect: () => void; onConnect: () => void;
onDisconnect: () => void; onDisconnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
@@ -83,6 +86,8 @@ const Sidebar = ({
setBearerToken, setBearerToken,
headerName, headerName,
setHeaderName, setHeaderName,
customHeaders,
setCustomHeaders,
onConnect, onConnect,
onDisconnect, onDisconnect,
stdErrNotifications, stdErrNotifications,
@@ -101,6 +106,7 @@ const Sidebar = ({
const [copiedServerEntry, setCopiedServerEntry] = useState(false); const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false); const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const [showCustomHeaders, setShowCustomHeaders] = useState(false);
// Reusable error reporter for copy actions // Reusable error reporter for copy actions
const reportError = useCallback( const reportError = useCallback(
@@ -213,6 +219,22 @@ const Sidebar = ({
} }
}, [generateMCPServerFile, toast, reportError]); }, [generateMCPServerFile, toast, reportError]);
const removeCustomHeader = (index: number) => {
const newHeaders = [...customHeaders];
newHeaders.splice(index, 1);
setCustomHeaders(newHeaders);
};
const updateCustomHeader = (index: number, field: 'key' | 'value', value: string) => {
const newArr = [...customHeaders];
const [oldKey, oldValue] = newArr[index];
const newTuple: [string, string] = field === 'key'
? [value, oldValue]
: [oldKey, value];
newArr[index] = newTuple;
setCustomHeaders(newArr);
};
return ( return (
<div className="w-80 bg-card border-r border-border flex flex-col h-full"> <div className="w-80 bg-card border-r border-border flex flex-col h-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800"> <div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
@@ -720,6 +742,62 @@ const Sidebar = ({
</> </>
)} )}
</div> </div>
<div className="space-y-2">
<Button
variant="outline"
onClick={() => setShowCustomHeaders(!showCustomHeaders)}
className="flex items-center w-full"
data-testid="custom-headers-button"
aria-expanded={showCustomHeaders}
>
{showCustomHeaders ? (
<ChevronDown className="w-4 h-4 mr-2" />
) : (
<ChevronRight className="w-4 h-4 mr-2" />
)}
Custom Headers
</Button>
{showCustomHeaders && (
<div className="space-y-2">
{customHeaders.map((header, index) => (
<div key={index} className="space-y-2">
<label className="text-sm font-medium">Header Name</label>
<Input
placeholder="Header Name"
value={header[0]}
onChange={(e) => updateCustomHeader(index, 'key', e.target.value)}
className="font-mono"
/>
<label className="text-sm font-medium">Header Value</label>
<Input
placeholder="Header Value"
value={header[1]}
onChange={(e) => updateCustomHeader(index, 'value', e.target.value)}
className="font-mono"
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeCustomHeader(index)}
className="w-full"
>
Remove Header
</Button>
</div>
))}
<Button
variant="outline"
className="w-full mt-2"
onClick={() => {
setCustomHeaders([ ...customHeaders, ["", ""]]);
}}
>
Add Custom Header
</Button>
</div>
)}
</div>
</div> </div>
</div> </div>
<div className="p-4 border-t"> <div className="p-4 border-t">

View File

@@ -31,32 +31,36 @@ Object.defineProperty(navigator, "clipboard", {
// Setup fake timers // Setup fake timers
jest.useFakeTimers(); jest.useFakeTimers();
describe("Sidebar Environment Variables", () => { const defaultProps = {
const defaultProps = { connectionStatus: "disconnected" as const,
connectionStatus: "disconnected" as const, transportType: "stdio" as const,
transportType: "stdio" as const, setTransportType: jest.fn(),
setTransportType: jest.fn(), command: "",
command: "", setCommand: jest.fn(),
setCommand: jest.fn(), args: "",
args: "", setArgs: jest.fn(),
setArgs: jest.fn(), sseUrl: "",
sseUrl: "", setSseUrl: jest.fn(),
setSseUrl: jest.fn(), env: {},
env: {}, setEnv: jest.fn(),
setEnv: jest.fn(), bearerToken: "",
bearerToken: "", setBearerToken: jest.fn(),
setBearerToken: jest.fn(), headerName: "",
onConnect: jest.fn(), setHeaderName: jest.fn(),
onDisconnect: jest.fn(), customHeaders: [],
stdErrNotifications: [], setCustomHeaders: jest.fn(),
clearStdErrNotifications: jest.fn(), onConnect: jest.fn(),
logLevel: "info" as const, onDisconnect: jest.fn(),
sendLogLevelRequest: jest.fn(), stdErrNotifications: [],
loggingSupported: true, clearStdErrNotifications: jest.fn(),
config: DEFAULT_INSPECTOR_CONFIG, logLevel: "debug" as const,
setConfig: jest.fn(), sendLogLevelRequest: jest.fn(),
}; loggingSupported: false,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
describe("Sidebar Environment Variables", () => {
const renderSidebar = (props = {}) => { const renderSidebar = (props = {}) => {
return render( return render(
<TooltipProvider> <TooltipProvider>
@@ -870,3 +874,79 @@ describe("Sidebar Environment Variables", () => {
}); });
}); });
}); });
describe("Sidebar", () => {
it("renders", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("MCP Inspector")).toBeInTheDocument();
});
it("shows connect button when disconnected", () => {
render(<Sidebar {...defaultProps} />);
expect(screen.getByText("Connect")).toBeInTheDocument();
});
it("shows disconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Disconnect")).toBeInTheDocument();
});
it("shows reconnect button when connected", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="sse"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Reconnect")).toBeInTheDocument();
});
it("shows restart button when connected with stdio transport", () => {
render(
<Sidebar
{...defaultProps}
connectionStatus="connected"
transportType="stdio"
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
expect(screen.getByText("Restart")).toBeInTheDocument();
});
it("shows environment variables section when stdio transport is selected", () => {
render(
<Sidebar
{...defaultProps}
env={{ NEW_KEY: "new_value" }}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const envButton = screen.getByText("Environment Variables");
expect(envButton).toBeInTheDocument();
});
it("shows configuration section", () => {
render(
<Sidebar
{...defaultProps}
config={DEFAULT_INSPECTOR_CONFIG}
customHeaders={[]}
setCustomHeaders={jest.fn()}
/>,
);
const configButton = screen.getByText("Configuration");
expect(configButton).toBeInTheDocument();
});
});

View File

@@ -54,6 +54,7 @@ interface UseConnectionOptions {
env: Record<string, string>; env: Record<string, string>;
bearerToken?: string; bearerToken?: string;
headerName?: string; headerName?: string;
customHeaders?: [string, string][];
config: InspectorConfig; config: InspectorConfig;
onNotification?: (notification: Notification) => void; onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void; onStdErrNotification?: (notification: Notification) => void;
@@ -71,6 +72,7 @@ export function useConnection({
env, env,
bearerToken, bearerToken,
headerName, headerName,
customHeaders,
config, config,
onNotification, onNotification,
onStdErrNotification, onStdErrNotification,
@@ -287,7 +289,9 @@ export function useConnection({
try { try {
// Inject auth manually instead of using SSEClientTransport, because we're // Inject auth manually instead of using SSEClientTransport, because we're
// proxying through the inspector server first. // proxying through the inspector server first.
const headers: HeadersInit = {}; const headers = new Headers(customHeaders||[]);
//const headers: HeadersInit = [ ...customHeaders||[] ];
// 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);
@@ -297,7 +301,7 @@ export function useConnection({
bearerToken || (await serverAuthProvider.tokens())?.access_token; bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) { if (token) {
const authHeaderName = headerName || "Authorization"; const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`; headers.set(authHeaderName, `Bearer ${token}`);
} }
// Create appropriate transport // Create appropriate transport

View File

@@ -25,7 +25,7 @@
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["jest", "@testing-library/jest-dom", "node"] "types": ["jest", "@testing-library/jest-dom", "node", "react", "react-dom"]
}, },
"include": ["src"] "include": ["src"]
} }

784
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,14 @@ import { findActualExecutable } from "spawn-rx";
import mcpProxy from "./mcpProxy.js"; import mcpProxy from "./mcpProxy.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const SSE_HEADERS_PASSTHROUGH = [
"authorization",
"x-api-key",
"x-custom-header",
"x-auth-token",
"x-request-id",
"x-correlation-id"
];
const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [ const STREAMABLE_HTTP_HEADERS_PASSTHROUGH = [
"authorization", "authorization",
"mcp-session-id", "mcp-session-id",