Merge branch 'main' into clarify-proxy-role-in-readme

This commit is contained in:
Cliff Hall 2025-05-30 18:37:40 -04:00 • committed by GitHub
commit 291c59eaf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 164 additions and 56 deletions

View file

@ -19,6 +19,16 @@ Note that the proxy is not a network proxy for intercepting traffic. Instead, it
- Node.js: ^22.7.5 - Node.js: ^22.7.5
### Quick Start (UI mode)
To get up and running right away with the UI, just execute the following:
```bash
npx @modelcontextprotocol/inspector
```
The server will start up and the UI will be accessible at `http://localhost:6274`.
### From an MCP server repository ### From an MCP server repository
To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`: To inspect an MCP server implementation, there's no need to clone this repo. Instead, use `npx`. For example, if your server is built at `build/index.js`:

View file

@ -611,7 +611,7 @@ const App = () => {
className="w-full p-4" className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)} onValueChange={(value) => (window.location.hash = value)}
> >
<TabsList className="mb-4 p-0"> <TabsList className="mb-4 py-0">
<TabsTrigger <TabsTrigger
value="resources" value="resources"
disabled={!serverCapabilities?.resources} disabled={!serverCapabilities?.resources}
@ -662,7 +662,7 @@ const App = () => {
!serverCapabilities?.tools ? ( !serverCapabilities?.tools ? (
<> <>
<div className="flex items-center justify-center p-4"> <div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500"> <p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP The connected server does not support any MCP
capabilities capabilities
</p> </p>
@ -816,7 +816,7 @@ const App = () => {
</Tabs> </Tabs>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-full gap-4"> <div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500"> <p className="text-lg text-gray-500 dark:text-gray-400">
Connect to an MCP server to start inspecting Connect to an MCP server to start inspecting
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -841,7 +841,7 @@ const App = () => {
}} }}
> >
<div <div
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50" className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
onMouseDown={handleDragStart} onMouseDown={handleDragStart}
> >
<div className="w-8 h-1 rounded-full bg-border" /> <div className="w-8 h-1 rounded-full bg-border" />

View file

@ -29,7 +29,9 @@ const HistoryAndNotifications = ({
<div className="flex-1 overflow-y-auto p-4 border-r"> <div className="flex-1 overflow-y-auto p-4 border-r">
<h2 className="text-lg font-semibold mb-4">History</h2> <h2 className="text-lg font-semibold mb-4">History</h2>
{requestHistory.length === 0 ? ( {requestHistory.length === 0 ? (
<p className="text-sm text-gray-500 italic">No history yet</p> <p className="text-sm text-gray-500 dark:text-gray-400 italic">
No history yet
</p>
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{requestHistory {requestHistory
@ -38,7 +40,7 @@ const HistoryAndNotifications = ({
.map((request, index) => ( .map((request, index) => (
<li <li
key={index} key={index}
className="text-sm text-foreground bg-secondary p-2 rounded" className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
> >
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer"
@ -93,7 +95,9 @@ const HistoryAndNotifications = ({
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<h2 className="text-lg font-semibold mb-4">Server Notifications</h2> <h2 className="text-lg font-semibold mb-4">Server Notifications</h2>
{serverNotifications.length === 0 ? ( {serverNotifications.length === 0 ? (
<p className="text-sm text-gray-500 italic">No notifications yet</p> <p className="text-sm text-gray-500 dark:text-gray-400 italic">
No notifications yet
</p>
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{serverNotifications {serverNotifications
@ -102,7 +106,7 @@ const HistoryAndNotifications = ({
.map((notification, index) => ( .map((notification, index) => (
<li <li
key={index} key={index}
className="text-sm text-foreground bg-secondary p-2 rounded" className="text-sm text-foreground bg-secondary py-2 px-3 rounded"
> >
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer"

View file

@ -21,8 +21,8 @@ const ListPane = <T extends object>({
buttonText, buttonText,
isButtonDisabled, isButtonDisabled,
}: ListPaneProps<T>) => ( }: ListPaneProps<T>) => (
<div className="bg-card rounded-lg shadow"> <div className="bg-card border border-border 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-border">
<h3 className="font-semibold dark:text-white">{title}</h3> <h3 className="font-semibold dark:text-white">{title}</h3>
</div> </div>
<div className="p-4"> <div className="p-4">
@ -46,7 +46,7 @@ const ListPane = <T extends object>({
{items.map((item, index) => ( {items.map((item, index) => (
<div <div
key={index} key={index}
className="flex items-center p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer" className="flex items-center py-2 px-4 rounded hover:bg-gray-50 dark:hover:bg-secondary cursor-pointer"
onClick={() => setSelectedItem(item)} onClick={() => setSelectedItem(item)}
> >
{renderItem(item)} {renderItem(item)}

View file

@ -110,8 +110,8 @@ const PromptsTab = ({
isButtonDisabled={!nextCursor && prompts.length > 0} isButtonDisabled={!nextCursor && prompts.length > 0}
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card border border-border 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-border">
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedPrompt ? selectedPrompt.name : "Select a prompt"} {selectedPrompt ? selectedPrompt.name : "Select a prompt"}
</h3> </h3>
@ -126,7 +126,7 @@ const PromptsTab = ({
) : selectedPrompt ? ( ) : selectedPrompt ? (
<div className="space-y-4"> <div className="space-y-4">
{selectedPrompt.description && ( {selectedPrompt.description && (
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-400">
{selectedPrompt.description} {selectedPrompt.description}
</p> </p>
)} )}

View file

@ -173,8 +173,8 @@ const ResourcesTab = ({
isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0}
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card border border-border rounded-lg shadow">
<div className="p-4 border-b border-gray-200 dark:border-gray-800 flex justify-between items-center"> <div className="p-4 border-b border-gray-200 dark:border-border flex justify-between items-center">
<h3 <h3
className="font-semibold truncate" className="font-semibold truncate"
title={selectedResource?.name || selectedTemplate?.name} title={selectedResource?.name || selectedTemplate?.name}
@ -234,7 +234,7 @@ const ResourcesTab = ({
/> />
) : selectedTemplate ? ( ) : selectedTemplate ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-400">
{selectedTemplate.description} {selectedTemplate.description}
</p> </p>
{selectedTemplate.uriTemplate {selectedTemplate.uriTemplate

View file

@ -215,7 +215,7 @@ const Sidebar = ({
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-border">
<div className="flex items-center"> <div className="flex items-center">
<h1 className="ml-2 text-lg font-semibold"> <h1 className="ml-2 text-lg font-semibold">
MCP Inspector v{version} MCP Inspector v{version}
@ -646,7 +646,7 @@ const Sidebar = ({
} }
})()}`} })()}`}
/> />
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600 dark:text-gray-400">
{(() => { {(() => {
switch (connectionStatus) { switch (connectionStatus) {
case "connected": case "connected":

View file

@ -77,8 +77,8 @@ const ToolsTab = ({
isButtonDisabled={!nextCursor && tools.length > 0} isButtonDisabled={!nextCursor && tools.length > 0}
/> />
<div className="bg-card rounded-lg shadow"> <div className="bg-card border border-border 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-border">
<h3 className="font-semibold"> <h3 className="font-semibold">
{selectedTool ? selectedTool.name : "Select a tool"} {selectedTool ? selectedTool.name : "Select a tool"}
</h3> </h3>
@ -86,7 +86,7 @@ const ToolsTab = ({
<div className="p-4"> <div className="p-4">
{selectedTool ? ( {selectedTool ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600 dark:text-gray-400">
{selectedTool.description} {selectedTool.description}
</p> </p>
{Object.entries(selectedTool.inputSchema.properties ?? []).map( {Object.entries(selectedTool.inputSchema.properties ?? []).map(
@ -96,7 +96,7 @@ const ToolsTab = ({
<div key={key}> <div key={key}>
<Label <Label
htmlFor={key} htmlFor={key}
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-gray-700 dark:text-gray-300"
> >
{key} {key}
</Label> </Label>

View file

@ -93,8 +93,8 @@ h1 {
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 217.2 24% 24%;
--input: 217.2 32.6% 17.5%; --input: 217.2 24% 24%;
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;

View file

@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
})); }));
jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({
SSEClientTransport: jest.fn(), SSEClientTransport: jest.fn((url) => ({
toString: () => url,
})),
SseError: jest.fn(), SseError: jest.fn(),
})); }));
jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
StreamableHTTPClientTransport: jest.fn((url) => ({
toString: () => url,
})),
}));
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn().mockResolvedValue("AUTHORIZED"), auth: jest.fn().mockResolvedValue("AUTHORIZED"),
})); }));
@ -163,4 +171,92 @@ describe("useConnection", () => {
result.current.makeRequest(mockRequest, mockSchema), result.current.makeRequest(mockRequest, mockSchema),
).rejects.toThrow("MCP client not connected"); ).rejects.toThrow("MCP client not connected");
}); });
describe("URL Port Handling", () => {
const SSEClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/sse.js",
).SSEClientTransport;
const StreamableHTTPClientTransport = jest.requireMock(
"@modelcontextprotocol/sdk/client/streamableHttp.js",
).StreamableHTTPClientTransport;
beforeEach(() => {
jest.clearAllMocks();
});
test("preserves HTTPS port number when connecting", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com:8443/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
);
});
test("preserves HTTP port number when connecting", async () => {
const props = {
...defaultProps,
sseUrl: "http://localhost:3000/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=http%3A%2F%2Flocalhost%3A3000%2Fapi",
);
});
test("uses default port for HTTPS when not specified", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com/api",
transportType: "sse" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = SSEClientTransport.mock.calls[0][0];
expect(call.toString()).toContain("url=https%3A%2F%2Fexample.com%2Fapi");
expect(call.toString()).not.toContain("%3A443");
});
test("preserves port number in streamable-http transport", async () => {
const props = {
...defaultProps,
sseUrl: "https://example.com:8443/api",
transportType: "streamable-http" as const,
};
const { result } = renderHook(() => useConnection(props));
await act(async () => {
await result.current.connect();
});
const call = StreamableHTTPClientTransport.mock.calls[0][0];
expect(call.toString()).toContain(
"url=https%3A%2F%2Fexample.com%3A8443%2Fapi",
);
});
});
}); });

View file

@ -48,7 +48,8 @@ app.use((req, res, next) => {
next(); next();
}); });
const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Transports by sessionId const webAppTransports: Map<string, Transport> = new Map<string, Transport>(); // Web app transports by web app sessionId
const serverTransports: Map<string, Transport> = new Map<string, Transport>(); // Server Transports by web app sessionId
const createTransport = async (req: express.Request): Promise<Transport> => { const createTransport = async (req: express.Request): Promise<Transport> => {
const query = req.query; const query = req.query;
@ -137,8 +138,6 @@ const createTransport = async (req: express.Request): Promise<Transport> => {
} }
}; };
let backingServerTransport: Transport | undefined;
app.get("/mcp", async (req, res) => { app.get("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string; const sessionId = req.headers["mcp-session-id"] as string;
console.log(`Received GET message for sessionId ${sessionId}`); console.log(`Received GET message for sessionId ${sessionId}`);
@ -161,12 +160,12 @@ app.get("/mcp", async (req, res) => {
app.post("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => {
const sessionId = req.headers["mcp-session-id"] as string | undefined; const sessionId = req.headers["mcp-session-id"] as string | undefined;
console.log(`Received POST message for sessionId ${sessionId}`); console.log(`Received POST message for sessionId ${sessionId}`);
let serverTransport: Transport | undefined;
if (!sessionId) { if (!sessionId) {
try { try {
console.log("New streamable-http connection"); console.log("New streamable-http connection");
try { try {
await backingServerTransport?.close(); serverTransport = await createTransport(req);
backingServerTransport = await createTransport(req);
} catch (error) { } catch (error) {
if (error instanceof SseError && error.code === 401) { if (error instanceof SseError && error.code === 401) {
console.error( console.error(
@ -180,12 +179,13 @@ app.post("/mcp", async (req, res) => {
throw error; throw error;
} }
console.log("Connected MCP client to backing server transport"); console.log("Connected MCP client to server transport");
const webAppTransport = new StreamableHTTPServerTransport({ const webAppTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: randomUUID, sessionIdGenerator: randomUUID,
onsessioninitialized: (sessionId) => { onsessioninitialized: (sessionId) => {
webAppTransports.set(sessionId, webAppTransport); webAppTransports.set(sessionId, webAppTransport);
serverTransports.set(sessionId, serverTransport!);
console.log("Created streamable web app transport " + sessionId); console.log("Created streamable web app transport " + sessionId);
}, },
}); });
@ -194,7 +194,7 @@ app.post("/mcp", async (req, res) => {
mcpProxy({ mcpProxy({
transportToClient: webAppTransport, transportToClient: webAppTransport,
transportToServer: backingServerTransport, transportToServer: serverTransport,
}); });
await (webAppTransport as StreamableHTTPServerTransport).handleRequest( await (webAppTransport as StreamableHTTPServerTransport).handleRequest(
@ -229,10 +229,9 @@ app.post("/mcp", async (req, res) => {
app.get("/stdio", async (req, res) => { app.get("/stdio", async (req, res) => {
try { try {
console.log("New connection"); console.log("New connection");
let serverTransport: Transport | undefined;
try { try {
await backingServerTransport?.close(); serverTransport = await createTransport(req);
backingServerTransport = await createTransport(req);
} catch (error) { } catch (error) {
if (error instanceof SseError && error.code === 401) { if (error instanceof SseError && error.code === 401) {
console.error( console.error(
@ -250,26 +249,24 @@ app.get("/stdio", async (req, res) => {
const webAppTransport = new SSEServerTransport("/message", res); const webAppTransport = new SSEServerTransport("/message", res);
webAppTransports.set(webAppTransport.sessionId, webAppTransport); webAppTransports.set(webAppTransport.sessionId, webAppTransport);
serverTransports.set(webAppTransport.sessionId, serverTransport);
console.log("Created web app transport"); console.log("Created client/server transports");
await webAppTransport.start(); await webAppTransport.start();
(backingServerTransport as StdioClientTransport).stderr!.on(
"data", (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => {
(chunk) => { webAppTransport.send({
webAppTransport.send({ jsonrpc: "2.0",
jsonrpc: "2.0", method: "notifications/stderr",
method: "notifications/stderr", params: {
params: { content: chunk.toString(),
content: chunk.toString(), },
}, });
}); });
},
);
mcpProxy({ mcpProxy({
transportToClient: webAppTransport, transportToClient: webAppTransport,
transportToServer: backingServerTransport, transportToServer: serverTransport,
}); });
console.log("Set up MCP proxy"); console.log("Set up MCP proxy");
@ -284,10 +281,9 @@ app.get("/sse", async (req, res) => {
console.log( console.log(
"New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http",
); );
let serverTransport: Transport | undefined;
try { try {
await backingServerTransport?.close(); serverTransport = await createTransport(req);
backingServerTransport = await createTransport(req);
} catch (error) { } catch (error) {
if (error instanceof SseError && error.code === 401) { if (error instanceof SseError && error.code === 401) {
console.error( console.error(
@ -305,13 +301,15 @@ app.get("/sse", async (req, res) => {
const webAppTransport = new SSEServerTransport("/message", res); const webAppTransport = new SSEServerTransport("/message", res);
webAppTransports.set(webAppTransport.sessionId, webAppTransport); webAppTransports.set(webAppTransport.sessionId, webAppTransport);
console.log("Created web app transport"); console.log("Created client transport");
serverTransports.set(webAppTransport.sessionId, serverTransport);
console.log("Created server transport");
await webAppTransport.start(); await webAppTransport.start();
mcpProxy({ mcpProxy({
transportToClient: webAppTransport, transportToClient: webAppTransport,
transportToServer: backingServerTransport, transportToServer: serverTransport,
}); });
console.log("Set up MCP proxy"); console.log("Set up MCP proxy");