diff --git a/README.md b/README.md index d7c34b97..9d5017dc 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,31 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. ![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/main/mcp-inspector.png) +## Architecture Overview + +The MCP Inspector consists of two main components that work together: + +- **MCP Inspector Client (MCPI)**: A React-based web UI that provides an interactive interface for testing and debugging MCP servers +- **MCP Proxy (MCPP)**: A Node.js server that acts as a protocol bridge, connecting the web UI to MCP servers via various transport methods (stdio, SSE, streamable-http) + +Note that the proxy is not a network proxy for intercepting traffic. Instead, it functions as both an MCP client (connecting to your MCP server) and an HTTP server (serving the web UI), enabling browser-based interaction with MCP servers that use different transport protocols. + ## Running the Inspector ### Requirements - 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 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`: diff --git a/client/src/App.tsx b/client/src/App.tsx index a332f9aa..00d3bb48 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -653,7 +653,7 @@ const App = () => { className="w-full p-4" onValueChange={(value) => (window.location.hash = value)} > - + { !serverCapabilities?.tools ? ( <>
-

+

The connected server does not support any MCP capabilities

@@ -858,7 +858,7 @@ const App = () => { ) : (
-

+

Connect to an MCP server to start inspecting

@@ -883,7 +883,7 @@ const App = () => { }} >
diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 0b05b55d..78394dea 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -29,7 +29,9 @@ const HistoryAndNotifications = ({

History

{requestHistory.length === 0 ? ( -

No history yet

+

+ No history yet +

) : (
    {requestHistory @@ -38,7 +40,7 @@ const HistoryAndNotifications = ({ .map((request, index) => (
  • Server Notifications

    {serverNotifications.length === 0 ? ( -

    No notifications yet

    +

    + No notifications yet +

    ) : (
      {serverNotifications @@ -102,7 +106,7 @@ const HistoryAndNotifications = ({ .map((notification, index) => (
    • ({ buttonText, isButtonDisabled, }: ListPaneProps) => ( -
      -
      +
      +

      {title}

      @@ -46,7 +46,7 @@ const ListPane = ({ {items.map((item, index) => (
      setSelectedItem(item)} > {renderItem(item)} diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index 5fdad823..66c15d8f 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -110,8 +110,8 @@ const PromptsTab = ({ isButtonDisabled={!nextCursor && prompts.length > 0} /> -
      -
      +
      +

      {selectedPrompt ? selectedPrompt.name : "Select a prompt"}

      @@ -126,7 +126,7 @@ const PromptsTab = ({ ) : selectedPrompt ? (
      {selectedPrompt.description && ( -

      +

      {selectedPrompt.description}

      )} diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index c0f8203f..35742799 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -173,8 +173,8 @@ const ResourcesTab = ({ isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} /> -
      -
      +
      +

      ) : selectedTemplate ? (
      -

      +

      {selectedTemplate.description}

      {selectedTemplate.uriTemplate diff --git a/client/src/components/Sidebar.tsx b/client/src/components/Sidebar.tsx index 516f48d5..eee8eeaa 100644 --- a/client/src/components/Sidebar.tsx +++ b/client/src/components/Sidebar.tsx @@ -215,7 +215,7 @@ const Sidebar = ({ return (
      -
      +

      MCP Inspector v{version} @@ -646,7 +646,7 @@ const Sidebar = ({ } })()}`} /> - + {(() => { switch (connectionStatus) { case "connected": diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8c72bd2a..8a7f6578 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -77,8 +77,8 @@ const ToolsTab = ({ isButtonDisabled={!nextCursor && tools.length > 0} /> -
      -
      +
      +

      {selectedTool ? selectedTool.name : "Select a tool"}

      @@ -86,7 +86,7 @@ const ToolsTab = ({
      {selectedTool ? (
      -

      +

      {selectedTool.description}

      {Object.entries(selectedTool.inputSchema.properties ?? []).map( @@ -96,7 +96,7 @@ const ToolsTab = ({
      diff --git a/client/src/index.css b/client/src/index.css index 11c6f23d..858f9e7f 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -93,8 +93,8 @@ h1 { --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --border: 217.2 24% 24%; + --input: 217.2 24% 24%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; diff --git a/client/src/lib/hooks/__tests__/useConnection.test.tsx b/client/src/lib/hooks/__tests__/useConnection.test.tsx index c6700dcf..6e5748d7 100644 --- a/client/src/lib/hooks/__tests__/useConnection.test.tsx +++ b/client/src/lib/hooks/__tests__/useConnection.test.tsx @@ -28,10 +28,18 @@ jest.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ })); jest.mock("@modelcontextprotocol/sdk/client/sse.js", () => ({ - SSEClientTransport: jest.fn(), + SSEClientTransport: jest.fn((url) => ({ + toString: () => url, + })), SseError: jest.fn(), })); +jest.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({ + StreamableHTTPClientTransport: jest.fn((url) => ({ + toString: () => url, + })), +})); + jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn().mockResolvedValue("AUTHORIZED"), })); @@ -163,4 +171,92 @@ describe("useConnection", () => { result.current.makeRequest(mockRequest, mockSchema), ).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", + ); + }); + }); }); diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index 9c825318..d42b8d38 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -45,6 +45,7 @@ import { } from "@/utils/configUtils"; import { getMCPServerRequestTimeout } from "@/utils/configUtils"; import { InspectorConfig } from "../configurationTypes"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; interface UseConnectionOptions { transportType: "stdio" | "sse" | "streamable-http"; @@ -83,6 +84,9 @@ export function useConnection({ const [serverCapabilities, setServerCapabilities] = useState(null); const [mcpClient, setMcpClient] = useState(null); + const [clientTransport, setClientTransport] = useState( + null, + ); const [requestHistory, setRequestHistory] = useState< { request: string; response?: string }[] >([]); @@ -304,7 +308,14 @@ export function useConnection({ bearerToken || (await serverAuthProvider.tokens())?.access_token; if (token) { const authHeaderName = headerName || "Authorization"; - headers[authHeaderName] = `Bearer ${token}`; + + // Add custom header name as a special request header to let the server know which header to pass through + if (authHeaderName.toLowerCase() !== "authorization") { + headers[authHeaderName] = token; + headers["x-custom-auth-header"] = authHeaderName; + } else { + headers[authHeaderName] = `Bearer ${token}`; + } } // Create appropriate transport @@ -377,14 +388,6 @@ export function useConnection({ transportType, ); - const clientTransport = - transportType === "streamable-http" - ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { - sessionId: undefined, - ...transportOptions, - }) - : new SSEClientTransport(mcpProxyServerUrl as URL, transportOptions); - if (onNotification) { [ CancelledNotificationSchema, @@ -414,7 +417,20 @@ export function useConnection({ let capabilities; try { - await client.connect(clientTransport); + const transport = + transportType === "streamable-http" + ? new StreamableHTTPClientTransport(mcpProxyServerUrl as URL, { + sessionId: undefined, + ...transportOptions, + }) + : new SSEClientTransport( + mcpProxyServerUrl as URL, + transportOptions, + ); + + await client.connect(transport as Transport); + + setClientTransport(transport); capabilities = client.getServerCapabilities(); const initializeRequest = { @@ -468,10 +484,15 @@ export function useConnection({ }; const disconnect = async () => { + if (transportType === "streamable-http") + await ( + clientTransport as StreamableHTTPClientTransport + ).terminateSession(); await mcpClient?.close(); const authProvider = new InspectorOAuthClientProvider(sseUrl); authProvider.clear(); setMcpClient(null); + setClientTransport(null); setConnectionStatus("disconnected"); setCompletionsSupported(false); setServerCapabilities(null); diff --git a/server/src/index.ts b/server/src/index.ts index c967b60c..51e30a08 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,44 @@ const { values } = parseArgs({ }, }); +// Function to get HTTP headers. +// Supports only "sse" and "streamable-http" transport types. +const getHttpHeaders = ( + req: express.Request, + transportType: string, +): HeadersInit => { + const headers: HeadersInit = { + Accept: + transportType === "sse" + ? "text/event-stream" + : "text/event-stream, application/json", + }; + const defaultHeaders = + transportType === "sse" + ? SSE_HEADERS_PASSTHROUGH + : STREAMABLE_HTTP_HEADERS_PASSTHROUGH; + + for (const key of defaultHeaders) { + if (req.headers[key] === undefined) { + continue; + } + + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + // If the header "x-custom-auth-header" is present, use its value as the custom header name. + if (req.headers["x-custom-auth-header"] !== undefined) { + const customHeaderName = req.headers["x-custom-auth-header"] as string; + const lowerCaseHeaderName = customHeaderName.toLowerCase(); + if (req.headers[lowerCaseHeaderName] !== undefined) { + const value = req.headers[lowerCaseHeaderName]; + headers[customHeaderName] = value as string; + } + } + return headers; +}; + const app = express(); app.use(cors()); app.use((req, res, next) => { @@ -48,7 +86,8 @@ app.use((req, res, next) => { next(); }); -const webAppTransports: Map = new Map(); // Transports by sessionId +const webAppTransports: Map = new Map(); // Web app transports by web app sessionId +const serverTransports: Map = new Map(); // Server Transports by web app sessionId const createTransport = async (req: express.Request): Promise => { const query = req.query; @@ -79,18 +118,8 @@ const createTransport = async (req: express.Request): Promise => { return transport; } else if (transportType === "sse") { const url = query.url as string; - const headers: HeadersInit = { - Accept: "text/event-stream", - }; - for (const key of SSE_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } + const headers = getHttpHeaders(req, transportType); console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); @@ -107,18 +136,7 @@ const createTransport = async (req: express.Request): Promise => { console.log("Connected to SSE transport"); return transport; } else if (transportType === "streamable-http") { - const headers: HeadersInit = { - Accept: "text/event-stream, application/json", - }; - - for (const key of STREAMABLE_HTTP_HEADERS_PASSTHROUGH) { - if (req.headers[key] === undefined) { - continue; - } - - const value = req.headers[key]; - headers[key] = Array.isArray(value) ? value[value.length - 1] : value; - } + const headers = getHttpHeaders(req, transportType); const transport = new StreamableHTTPClientTransport( new URL(query.url as string), @@ -137,8 +155,6 @@ const createTransport = async (req: express.Request): Promise => { } }; -let backingServerTransport: Transport | undefined; - app.get("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string; console.log(`Received GET message for sessionId ${sessionId}`); @@ -161,12 +177,12 @@ app.get("/mcp", async (req, res) => { app.post("/mcp", async (req, res) => { const sessionId = req.headers["mcp-session-id"] as string | undefined; console.log(`Received POST message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; if (!sessionId) { try { console.log("New streamable-http connection"); try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -180,12 +196,13 @@ app.post("/mcp", async (req, res) => { throw error; } - console.log("Connected MCP client to backing server transport"); + console.log("Connected MCP client to server transport"); const webAppTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID, onsessioninitialized: (sessionId) => { webAppTransports.set(sessionId, webAppTransport); + serverTransports.set(sessionId, serverTransport!); console.log("Created streamable web app transport " + sessionId); }, }); @@ -194,7 +211,7 @@ app.post("/mcp", async (req, res) => { mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); await (webAppTransport as StreamableHTTPServerTransport).handleRequest( @@ -226,13 +243,39 @@ app.post("/mcp", async (req, res) => { } }); +app.delete("/mcp", async (req, res) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + console.log(`Received DELETE message for sessionId ${sessionId}`); + let serverTransport: Transport | undefined; + if (sessionId) { + try { + serverTransport = serverTransports.get( + sessionId, + ) as StreamableHTTPClientTransport; + if (!serverTransport) { + res.status(404).end("Transport not found for sessionId " + sessionId); + } else { + await ( + serverTransport as StreamableHTTPClientTransport + ).terminateSession(); + webAppTransports.delete(sessionId); + serverTransports.delete(sessionId); + console.log(`Transports removed for sessionId ${sessionId}`); + } + res.status(200).end(); + } catch (error) { + console.error("Error in /mcp route:", error); + res.status(500).json(error); + } + } +}); + app.get("/stdio", async (req, res) => { try { console.log("New connection"); - + let serverTransport: Transport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -250,26 +293,24 @@ app.get("/stdio", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); webAppTransports.set(webAppTransport.sessionId, webAppTransport); - - console.log("Created web app transport"); + serverTransports.set(webAppTransport.sessionId, serverTransport); + console.log("Created client/server transports"); await webAppTransport.start(); - (backingServerTransport as StdioClientTransport).stderr!.on( - "data", - (chunk) => { - webAppTransport.send({ - jsonrpc: "2.0", - method: "notifications/stderr", - params: { - content: chunk.toString(), - }, - }); - }, - ); + + (serverTransport as StdioClientTransport).stderr!.on("data", (chunk) => { + webAppTransport.send({ + jsonrpc: "2.0", + method: "notifications/stderr", + params: { + content: chunk.toString(), + }, + }); + }); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy"); @@ -284,10 +325,9 @@ app.get("/sse", async (req, res) => { console.log( "New SSE connection. NOTE: The sse transport is deprecated and has been replaced by streamable-http", ); - + let serverTransport: Transport | undefined; try { - await backingServerTransport?.close(); - backingServerTransport = await createTransport(req); + serverTransport = await createTransport(req); } catch (error) { if (error instanceof SseError && error.code === 401) { console.error( @@ -305,13 +345,15 @@ app.get("/sse", async (req, res) => { const webAppTransport = new SSEServerTransport("/message", res); 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(); mcpProxy({ transportToClient: webAppTransport, - transportToServer: backingServerTransport, + transportToServer: serverTransport, }); console.log("Set up MCP proxy");