Compare commits

...

11 Commits

12 changed files with 988 additions and 187 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

@@ -1,3 +1,9 @@
# Original Repo: [https://github.com/modelcontextprotocol/inspector](https://github.com/modelcontextprotocol/inspector)
## Modification Log
- Containerize
- Support custom headers for sse transport
# MCP Inspector # MCP Inspector
The MCP inspector is a developer tool for testing and debugging MCP servers. The MCP inspector is a developer tool for testing and debugging MCP servers.

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">
@@ -340,7 +362,64 @@ const Sidebar = ({
</div> </div>
</> </>
)} )}
{transportType === "sse" && (
<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>
)}
{transportType === "stdio" && ( {transportType === "stdio" && (
<div className="space-y-2"> <div className="space-y-2">
<Button <Button
@@ -720,6 +799,8 @@ const Sidebar = ({
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="p-4 border-t"> <div className="p-4 border-t">

View File

@@ -41,6 +41,7 @@ const ToolsTab = ({
}) => { }) => {
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
const [isToolRunning, setIsToolRunning] = useState(false); const [isToolRunning, setIsToolRunning] = useState(false);
const [filterText, setFilterText] = useState("");
useEffect(() => { useEffect(() => {
const params = Object.entries( const params = Object.entries(
@@ -52,6 +53,12 @@ const ToolsTab = ({
setParams(Object.fromEntries(params)); setParams(Object.fromEntries(params));
}, [selectedTool]); }, [selectedTool]);
const filteredTools = filterText
? tools.filter((tool) =>
tool.name.toLowerCase().includes(filterText.toLowerCase())
)
: tools;
const renderToolResult = () => { const renderToolResult = () => {
if (!toolResult) return null; if (!toolResult) return null;
@@ -124,27 +131,34 @@ const ToolsTab = ({
return ( return (
<TabsContent value="tools"> <TabsContent value="tools">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<ListPane <div className="space-y-2">
items={tools} <Input
listItems={listTools} placeholder="Filter tools by name..."
clearItems={() => { value={filterText}
clearTools(); onChange={(e) => setFilterText(e.target.value)}
setSelectedTool(null); className="w-full"
}} />
setSelectedItem={setSelectedTool} <ListPane
renderItem={(tool) => ( items={filteredTools}
<div className="flex flex-col items-start"> listItems={listTools}
<span className="flex-1">{tool.name}</span> clearItems={() => {
<span className="text-sm text-gray-500 text-left"> clearTools();
{tool.description} setSelectedTool(null);
</span> }}
</div> setSelectedItem={setSelectedTool}
)} renderItem={(tool) => (
title="Tools" <div className="flex flex-col items-start">
buttonText={nextCursor ? "List More Tools" : "List Tools"} <span className="flex-1">{tool.name}</span>
isButtonDisabled={!nextCursor && tools.length > 0} <span className="text-sm text-gray-500 text-left">
/> {tool.description}
</span>
</div>
)}
title="Tools"
buttonText={nextCursor ? "List More Tools" : "List Tools"}
isButtonDisabled={!nextCursor && tools.length > 0}
/>
</div>
<div className="bg-card rounded-lg shadow"> <div className="bg-card rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800"> <div className="p-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="font-semibold"> <h3 className="font-semibold">
@@ -301,4 +315,4 @@ const ToolsTab = ({
); );
}; };
export default ToolsTab; export default ToolsTab;

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",