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 . .
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
FROM node:20-alpine
@@ -22,5 +36,4 @@ RUN npm ci --omit=dev --ignore-scripts
ENV NODE_ENV=production
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
The MCP inspector is a developer tool for testing and debugging MCP servers.

View File

@@ -51,7 +51,7 @@
"devDependencies": {
"@eslint/js": "^9.11.1",
"@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/node": "^22.7.5",
"@types/prismjs": "^1.26.5",

View File

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

View File

@@ -40,7 +40,8 @@ import {
} from "@/components/ui/tooltip";
import { useToast } from "../lib/hooks/useToast";
interface SidebarProps {
export interface SidebarProps {
connectionStatus: ConnectionStatus;
transportType: "stdio" | "sse" | "streamable-http";
setTransportType: (type: "stdio" | "sse" | "streamable-http") => void;
@@ -56,6 +57,8 @@ interface SidebarProps {
setBearerToken: (token: string) => void;
headerName?: string;
setHeaderName?: (name: string) => void;
customHeaders: [string, string][];
setCustomHeaders: (headers: [string, string][]) => void;
onConnect: () => void;
onDisconnect: () => void;
stdErrNotifications: StdErrNotification[];
@@ -83,6 +86,8 @@ const Sidebar = ({
setBearerToken,
headerName,
setHeaderName,
customHeaders,
setCustomHeaders,
onConnect,
onDisconnect,
stdErrNotifications,
@@ -101,6 +106,7 @@ const Sidebar = ({
const [copiedServerEntry, setCopiedServerEntry] = useState(false);
const [copiedServerFile, setCopiedServerFile] = useState(false);
const { toast } = useToast();
const [showCustomHeaders, setShowCustomHeaders] = useState(false);
// Reusable error reporter for copy actions
const reportError = useCallback(
@@ -213,6 +219,22 @@ const Sidebar = ({
}
}, [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 (
<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">
@@ -340,7 +362,64 @@ const Sidebar = ({
</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" && (
<div className="space-y-2">
<Button
@@ -720,6 +799,8 @@ const Sidebar = ({
</>
)}
</div>
</div>
</div>
<div className="p-4 border-t">

View File

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

View File

@@ -31,7 +31,6 @@ Object.defineProperty(navigator, "clipboard", {
// Setup fake timers
jest.useFakeTimers();
describe("Sidebar Environment Variables", () => {
const defaultProps = {
connectionStatus: "disconnected" as const,
transportType: "stdio" as const,
@@ -46,17 +45,22 @@ describe("Sidebar Environment Variables", () => {
setEnv: jest.fn(),
bearerToken: "",
setBearerToken: jest.fn(),
headerName: "",
setHeaderName: jest.fn(),
customHeaders: [],
setCustomHeaders: jest.fn(),
onConnect: jest.fn(),
onDisconnect: jest.fn(),
stdErrNotifications: [],
clearStdErrNotifications: jest.fn(),
logLevel: "info" as const,
logLevel: "debug" as const,
sendLogLevelRequest: jest.fn(),
loggingSupported: true,
loggingSupported: false,
config: DEFAULT_INSPECTOR_CONFIG,
setConfig: jest.fn(),
};
describe("Sidebar Environment Variables", () => {
const renderSidebar = (props = {}) => {
return render(
<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>;
bearerToken?: string;
headerName?: string;
customHeaders?: [string, string][];
config: InspectorConfig;
onNotification?: (notification: Notification) => void;
onStdErrNotification?: (notification: Notification) => void;
@@ -71,6 +72,7 @@ export function useConnection({
env,
bearerToken,
headerName,
customHeaders,
config,
onNotification,
onStdErrNotification,
@@ -287,7 +289,9 @@ export function useConnection({
try {
// Inject auth manually instead of using SSEClientTransport, because we're
// 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
const serverAuthProvider = new InspectorOAuthClientProvider(sseUrl);
@@ -297,7 +301,7 @@ export function useConnection({
bearerToken || (await serverAuthProvider.tokens())?.access_token;
if (token) {
const authHeaderName = headerName || "Authorization";
headers[authHeaderName] = `Bearer ${token}`;
headers.set(authHeaderName, `Bearer ${token}`);
}
// Create appropriate transport

View File

@@ -25,7 +25,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true,
"types": ["jest", "@testing-library/jest-dom", "node"]
"types": ["jest", "@testing-library/jest-dom", "node", "react", "react-dom"]
},
"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 { 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 = [
"authorization",
"mcp-session-id",