Compare commits

...

3 Commits

Author SHA1 Message Date
d3c0217681 add: support custom headers 2025-05-16 15:31:23 +01:00
c843005928 Merge pull request 'add: Dockerfile' (#1) from create_docker_file into main
Reviewed-on: #1
2025-05-16 11:25:36 +00:00
b2258a1e83 Delete .github/workflows/main.yml 2025-05-16 11:25:01 +00:00
10 changed files with 943 additions and 165 deletions

View File

@@ -1,64 +0,0 @@
on:
push:
branches:
- main
pull_request:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check formatting
run: npx prettier --check .
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
# Working around https://github.com/npm/cli/issues/4828
# - run: npm ci
- run: npm install --no-package-lock
- name: Check linting
working-directory: ./client
run: npm run lint
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'
environment: release
needs: build
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
cache: npm
registry-url: "https://registry.npmjs.org"
# Working around https://github.com/npm/cli/issues/4828
# - run: npm ci
- run: npm install --no-package-lock
# TODO: Add --provenance once the repo is public
- run: npm run publish-all
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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,8 +31,7 @@ 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(),
@@ -46,17 +45,22 @@ describe("Sidebar Environment Variables", () => {
setEnv: jest.fn(), setEnv: jest.fn(),
bearerToken: "", bearerToken: "",
setBearerToken: jest.fn(), setBearerToken: jest.fn(),
headerName: "",
setHeaderName: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(), onConnect: jest.fn(),
onDisconnect: jest.fn(), onDisconnect: jest.fn(),
stdErrNotifications: [], stdErrNotifications: [],
clearStdErrNotifications: jest.fn(), clearStdErrNotifications: jest.fn(),
logLevel: "info" as const, logLevel: "debug" as const,
sendLogLevelRequest: jest.fn(), sendLogLevelRequest: jest.fn(),
loggingSupported: true, loggingSupported: false,
config: DEFAULT_INSPECTOR_CONFIG, config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(), 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",