diff --git a/README.md b/README.md index 98b5704..3ffeae9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The MCP inspector is a developer tool for testing and debugging MCP servers. 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`: ```bash -npx @modelcontextprotocol/inspector build/index.js +npx @modelcontextprotocol/inspector node build/index.js ``` You can pass both arguments and environment variables to your MCP server. Arguments are passed directly to your server, while environment variables can be set using the `-e` flag: @@ -21,19 +21,19 @@ You can pass both arguments and environment variables to your MCP server. Argume npx @modelcontextprotocol/inspector build/index.js arg1 arg2 # Pass environment variables only -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js # Pass both environment variables and arguments -npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 build/index.js arg1 arg2 +npx @modelcontextprotocol/inspector -e KEY=value -e KEY2=$VALUE2 node build/index.js arg1 arg2 # Use -- to separate inspector flags from server arguments -npx @modelcontextprotocol/inspector -e KEY=$VALUE -- build/index.js -e server-flag +npx @modelcontextprotocol/inspector -e KEY=$VALUE -- node build/index.js -e server-flag ``` The inspector runs both a client UI (default port 5173) and an MCP proxy server (default port 3000). Open the client UI in your browser to use the inspector. You can customize the ports if needed: ```bash -CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector build/index.js +CLIENT_PORT=8080 SERVER_PORT=9000 npx @modelcontextprotocol/inspector node build/index.js ``` For more details on ways to use the inspector, see the [Inspector section of the MCP docs site](https://modelcontextprotocol.io/docs/tools/inspector). For help with debugging, see the [Debugging guide](https://modelcontextprotocol.io/docs/tools/debugging). diff --git a/client/package.json b/client/package.json index cadfca9..c32dfa1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "0.3.0", + "version": "0.4.1", "description": "Client-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -21,7 +21,7 @@ "preview": "vite preview" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", @@ -32,6 +32,7 @@ "clsx": "^2.1.1", "lucide-react": "^0.447.0", "prismjs": "^1.29.0", + "pkce-challenge": "^4.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-simple-code-editor": "^0.14.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index f3791b2..246e035 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,3 @@ -import { useDraggablePane } from "./lib/hooks/useDraggablePane"; -import { useConnection } from "./lib/hooks/useConnection"; import { ClientRequest, CompatibilityCallToolResult, @@ -10,15 +8,17 @@ import { ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, - ReadResourceResultSchema, ListToolsResultSchema, + ReadResourceResultSchema, Resource, ResourceTemplate, Root, ServerNotification, Tool, } from "@modelcontextprotocol/sdk/types.js"; -import { useEffect, useRef, useState } from "react"; +import React, { Suspense, useEffect, useRef, useState } from "react"; +import { useConnection } from "./lib/hooks/useConnection"; +import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; @@ -32,6 +32,7 @@ import { MessageSquare, } from "lucide-react"; +import { toast } from "react-toastify"; import { z } from "zod"; import "./App.css"; import ConsoleTab from "./components/ConsoleTab"; @@ -49,6 +50,17 @@ const PROXY_PORT = params.get("proxyPort") ?? "3000"; const PROXY_SERVER_URL = `http://localhost:${PROXY_PORT}`; const App = () => { + // Handle OAuth callback route + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); + return ( + Loading...}> + + + ); + } const [resources, setResources] = useState([]); const [resourceTemplates, setResourceTemplates] = useState< ResourceTemplate[] @@ -71,8 +83,14 @@ const App = () => { return localStorage.getItem("lastArgs") || ""; }); - const [sseUrl, setSseUrl] = useState("http://localhost:3001/sse"); - const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); + const [sseUrl, setSseUrl] = useState(() => { + return localStorage.getItem("lastSseUrl") || "http://localhost:3001/sse"; + }); + const [transportType, setTransportType] = useState<"stdio" | "sse">(() => { + return ( + (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" + ); + }); const [notifications, setNotifications] = useState([]); const [stdErrNotifications, setStdErrNotifications] = useState< StdErrNotification[] @@ -190,6 +208,31 @@ const App = () => { localStorage.setItem("lastArgs", args); }, [args]); + useEffect(() => { + localStorage.setItem("lastSseUrl", sseUrl); + }, [sseUrl]); + + useEffect(() => { + localStorage.setItem("lastTransportType", transportType); + }, [transportType]); + + // Auto-connect if serverUrl is provided in URL params (e.g. after OAuth callback) + useEffect(() => { + const serverUrl = params.get("serverUrl"); + if (serverUrl) { + setSseUrl(serverUrl); + setTransportType("sse"); + // Remove serverUrl from URL without reloading the page + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete("serverUrl"); + window.history.replaceState({}, "", newUrl.toString()); + // Show success toast for OAuth + toast.success("Successfully authenticated with OAuth"); + // Connect to the server + connectMcpServer(); + } + }, []); + useEffect(() => { fetch(`${PROXY_SERVER_URL}/config`) .then((response) => response.json()) diff --git a/client/src/components/OAuthCallback.tsx b/client/src/components/OAuthCallback.tsx new file mode 100644 index 0000000..869eef1 --- /dev/null +++ b/client/src/components/OAuthCallback.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef } from "react"; +import { handleOAuthCallback } from "../lib/auth"; +import { SESSION_KEYS } from "../lib/constants"; + +const OAuthCallback = () => { + const hasProcessedRef = useRef(false); + + useEffect(() => { + const handleCallback = async () => { + // Skip if we've already processed this callback + if (hasProcessedRef.current) { + return; + } + hasProcessedRef.current = true; + + const params = new URLSearchParams(window.location.search); + const code = params.get("code"); + const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); + + if (!code || !serverUrl) { + console.error("Missing code or server URL"); + window.location.href = "/"; + return; + } + + try { + const tokens = await handleOAuthCallback(serverUrl, code); + // Store both access and refresh tokens + sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + sessionStorage.setItem( + SESSION_KEYS.REFRESH_TOKEN, + tokens.refresh_token, + ); + } + // Redirect back to the main app with server URL to trigger auto-connect + window.location.href = `/?serverUrl=${encodeURIComponent(serverUrl)}`; + } catch (error) { + console.error("OAuth callback error:", error); + window.location.href = "/"; + } + }; + + void handleCallback(); + }, []); + + return ( +
+

Processing OAuth callback...

+
+ ); +}; + +export default OAuthCallback; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 8515d70..77a1cd2 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -94,11 +94,20 @@ const ToolsTab = ({ className="max-w-full h-auto" /> )} - {item.type === "resource" && ( -
-                  {JSON.stringify(item.resource, null, 2)}
-                
- )} + {item.type === "resource" && + (item.resource?.mimeType?.startsWith("audio/") ? ( + + ) : ( +
+                    {JSON.stringify(item.resource, null, 2)}
+                  
+ ))} ))} diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts new file mode 100644 index 0000000..592dc17 --- /dev/null +++ b/client/src/lib/auth.ts @@ -0,0 +1,134 @@ +import pkceChallenge from "pkce-challenge"; +import { SESSION_KEYS } from "./constants"; +import { z } from "zod"; + +export const OAuthMetadataSchema = z.object({ + authorization_endpoint: z.string(), + token_endpoint: z.string(), +}); + +export type OAuthMetadata = z.infer; + +export const OAuthTokensSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + expires_in: z.number().optional(), +}); + +export type OAuthTokens = z.infer; + +export async function discoverOAuthMetadata( + serverUrl: string, +): Promise { + try { + const url = new URL("/.well-known/oauth-authorization-server", serverUrl); + const response = await fetch(url.toString()); + + if (response.ok) { + const metadata = await response.json(); + const validatedMetadata = OAuthMetadataSchema.parse({ + authorization_endpoint: metadata.authorization_endpoint, + token_endpoint: metadata.token_endpoint, + }); + return validatedMetadata; + } + } catch (error) { + console.warn("OAuth metadata discovery failed:", error); + } + + // Fall back to default endpoints + const baseUrl = new URL(serverUrl); + const defaultMetadata = { + authorization_endpoint: new URL("/authorize", baseUrl).toString(), + token_endpoint: new URL("/token", baseUrl).toString(), + }; + return OAuthMetadataSchema.parse(defaultMetadata); +} + +export async function startOAuthFlow(serverUrl: string): Promise { + // Generate PKCE challenge + const challenge = await pkceChallenge(); + const codeVerifier = challenge.code_verifier; + const codeChallenge = challenge.code_challenge; + + // Store code verifier for later use + sessionStorage.setItem(SESSION_KEYS.CODE_VERIFIER, codeVerifier); + + // Discover OAuth endpoints + const metadata = await discoverOAuthMetadata(serverUrl); + + // Build authorization URL + const authUrl = new URL(metadata.authorization_endpoint); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set( + "redirect_uri", + window.location.origin + "/oauth/callback", + ); + + return authUrl.toString(); +} + +export async function handleOAuthCallback( + serverUrl: string, + code: string, +): Promise { + // Get stored code verifier + const codeVerifier = sessionStorage.getItem(SESSION_KEYS.CODE_VERIFIER); + if (!codeVerifier) { + throw new Error("No code verifier found"); + } + + // Discover OAuth endpoints + const metadata = await discoverOAuthMetadata(serverUrl); + // Exchange code for tokens + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + code_verifier: codeVerifier, + redirect_uri: window.location.origin + "/oauth/callback", + }), + }); + + if (!response.ok) { + throw new Error("Token exchange failed"); + } + + const tokens = await response.json(); + return OAuthTokensSchema.parse(tokens); +} + +export async function refreshAccessToken( + serverUrl: string, +): Promise { + const refreshToken = sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN); + if (!refreshToken) { + throw new Error("No refresh token available"); + } + + const metadata = await discoverOAuthMetadata(serverUrl); + + const response = await fetch(metadata.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + throw new Error("Token refresh failed"); + } + + const tokens = await response.json(); + return OAuthTokensSchema.parse(tokens); +} diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts new file mode 100644 index 0000000..13a2370 --- /dev/null +++ b/client/src/lib/constants.ts @@ -0,0 +1,7 @@ +// OAuth-related session storage keys +export const SESSION_KEYS = { + CODE_VERIFIER: "mcp_code_verifier", + SERVER_URL: "mcp_server_url", + ACCESS_TOKEN: "mcp_access_token", + REFRESH_TOKEN: "mcp_refresh_token", +} as const; diff --git a/client/src/lib/hooks/useConnection.ts b/client/src/lib/hooks/useConnection.ts index ef937d1..6c42c3f 100644 --- a/client/src/lib/hooks/useConnection.ts +++ b/client/src/lib/hooks/useConnection.ts @@ -1,5 +1,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + SSEClientTransport, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; import { ClientNotification, ClientRequest, @@ -12,8 +15,10 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { useState } from "react"; import { toast } from "react-toastify"; -import { Notification, StdErrNotificationSchema } from "../notificationTypes"; import { z } from "zod"; +import { startOAuthFlow, refreshAccessToken } from "../auth"; +import { SESSION_KEYS } from "../constants"; +import { Notification, StdErrNotificationSchema } from "../notificationTypes"; const DEFAULT_REQUEST_TIMEOUT_MSEC = 10000; @@ -116,7 +121,49 @@ export function useConnection({ } }; - const connect = async () => { + const initiateOAuthFlow = async () => { + sessionStorage.removeItem(SESSION_KEYS.ACCESS_TOKEN); + sessionStorage.removeItem(SESSION_KEYS.REFRESH_TOKEN); + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); + const redirectUrl = await startOAuthFlow(sseUrl); + window.location.href = redirectUrl; + }; + + const handleTokenRefresh = async () => { + try { + const tokens = await refreshAccessToken(sseUrl); + sessionStorage.setItem(SESSION_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + sessionStorage.setItem( + SESSION_KEYS.REFRESH_TOKEN, + tokens.refresh_token, + ); + } + return tokens.access_token; + } catch (error) { + console.error("Token refresh failed:", error); + await initiateOAuthFlow(); + throw error; + } + }; + + const handleAuthError = async (error: unknown) => { + if (error instanceof SseError && error.code === 401) { + if (sessionStorage.getItem(SESSION_KEYS.REFRESH_TOKEN)) { + try { + await handleTokenRefresh(); + return true; + } catch (error) { + console.error("Token refresh failed:", error); + } + } else { + await initiateOAuthFlow(); + } + } + return false; + }; + + const connect = async (_e?: unknown, retryCount: number = 0) => { try { const client = new Client( { @@ -144,7 +191,20 @@ export function useConnection({ backendUrl.searchParams.append("url", sseUrl); } - const clientTransport = new SSEClientTransport(backendUrl); + const headers: HeadersInit = {}; + const accessToken = sessionStorage.getItem(SESSION_KEYS.ACCESS_TOKEN); + if (accessToken) { + headers["Authorization"] = `Bearer ${accessToken}`; + } + + const clientTransport = new SSEClientTransport(backendUrl, { + eventSourceInit: { + fetch: (url, init) => fetch(url, { ...init, headers }), + }, + requestInit: { + headers, + }, + }); if (onNotification) { client.setNotificationHandler( @@ -160,7 +220,21 @@ export function useConnection({ ); } - await client.connect(clientTransport); + try { + await client.connect(clientTransport); + } catch (error) { + console.error("Failed to connect to MCP server:", error); + const shouldRetry = await handleAuthError(error); + if (shouldRetry) { + return connect(undefined, retryCount + 1); + } + + if (error instanceof SseError && error.code === 401) { + // Don't set error state if we're about to redirect for auth + return; + } + throw error; + } const capabilities = client.getServerCapabilities(); setServerCapabilities(capabilities ?? null); diff --git a/client/vite.config.ts b/client/vite.config.ts index dd3bd01..b3d0f45 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,10 +1,11 @@ +import react from "@vitejs/plugin-react"; import path from "path"; import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: {}, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/package-lock.json b/package-lock.json index 3ed8523..c37ee6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "workspaces": [ "client", @@ -31,10 +31,10 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", @@ -44,6 +44,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.447.0", + "pkce-challenge": "^4.1.0", "prismjs": "^1.29.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1208,13 +1209,31 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.3.tgz", - "integrity": "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.4.1.tgz", + "integrity": "sha512-wS6YC4lkUZ9QpP+/7NBTlVNiEvsnyl0xF7rRusLF+RsG0xDPc/zWR7fEEyhKnnNutGsDAZh59l/AeoWGwIb1+g==", + "license": "MIT", "dependencies": { "content-type": "^1.0.5", + "eventsource": "^3.0.2", "raw-body": "^3.0.0", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/eventsource": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", + "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -2260,13 +2279,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eventsource": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", - "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -3697,19 +3709,19 @@ "node": ">= 0.6" } }, - "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "node_modules/eventsource-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.0.tgz", + "integrity": "sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==", "license": "MIT", "engines": { - "node": ">=12.0.0" + "node": ">=18.0.0" } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -3731,7 +3743,7 @@ "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", @@ -3746,6 +3758,10 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/debug": { @@ -4903,9 +4919,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, "node_modules/picocolors": { @@ -4944,6 +4960,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-4.1.0.tgz", + "integrity": "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", @@ -6955,22 +6980,30 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.1", "cors": "^2.8.5", - "eventsource": "^2.0.2", "express": "^4.21.0", "ws": "^8.18.0", "zod": "^3.23.8" @@ -6980,7 +7013,6 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/eventsource": "^1.1.15", "@types/express": "^4.17.21", "@types/ws": "^8.5.12", "tsx": "^4.19.0", diff --git a/package.json b/package.json index c262588..0989aa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "0.3.0", + "version": "0.4.1", "description": "Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -33,8 +33,8 @@ "publish-all": "npm publish --workspaces --access public && npm publish --access public" }, "dependencies": { - "@modelcontextprotocol/inspector-client": "0.3.0", - "@modelcontextprotocol/inspector-server": "0.3.0", + "@modelcontextprotocol/inspector-client": "0.4.1", + "@modelcontextprotocol/inspector-server": "0.4.1", "concurrently": "^9.0.1", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.0", diff --git a/server/package.json b/server/package.json index d41c2e3..05de0d1 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "0.3.0", + "version": "0.4.1", "description": "Server-side application for the Model Context Protocol inspector", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", @@ -20,16 +20,14 @@ }, "devDependencies": { "@types/cors": "^2.8.17", - "@types/eventsource": "^1.1.15", "@types/express": "^4.17.21", "@types/ws": "^8.5.12", "tsx": "^4.19.0", "typescript": "^5.6.2" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.3", + "@modelcontextprotocol/sdk": "^1.4.1", "cors": "^2.8.5", - "eventsource": "^2.0.2", "express": "^4.21.0", "ws": "^8.18.0", "zod": "^3.23.8" diff --git a/server/src/index.ts b/server/src/index.ts index 9662009..2874b45 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,29 +1,29 @@ #!/usr/bin/env node import cors from "cors"; -import EventSource from "eventsource"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { + SSEClientTransport, + SseError, +} from "@modelcontextprotocol/sdk/client/sse.js"; import { StdioClientTransport, getDefaultEnvironment, } from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; -import mcpProxy from "./mcpProxy.js"; import { findActualExecutable } from "spawn-rx"; +import mcpProxy from "./mcpProxy.js"; + +const SSE_HEADERS_PASSTHROUGH = ["authorization"]; const defaultEnvironment = { ...getDefaultEnvironment(), ...(process.env.MCP_ENV_VARS ? JSON.parse(process.env.MCP_ENV_VARS) : {}), }; -// Polyfill EventSource for an SSE client in Node.js -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(global as any).EventSource = EventSource; - const { values } = parseArgs({ args: process.argv.slice(2), options: { @@ -37,7 +37,8 @@ app.use(cors()); let webAppTransports: SSEServerTransport[] = []; -const createTransport = async (query: express.Request["query"]) => { +const createTransport = async (req: express.Request) => { + const query = req.query; console.log("Query parameters:", query); const transportType = query.transportType as string; @@ -65,9 +66,26 @@ const createTransport = async (query: express.Request["query"]) => { return transport; } else if (transportType === "sse") { const url = query.url as string; - console.log(`SSE transport: url=${url}`); + const headers: HeadersInit = {}; + for (const key of SSE_HEADERS_PASSTHROUGH) { + if (req.headers[key] === undefined) { + continue; + } - const transport = new SSEClientTransport(new URL(url)); + const value = req.headers[key]; + headers[key] = Array.isArray(value) ? value[value.length - 1] : value; + } + + console.log(`SSE transport: url=${url}, headers=${Object.keys(headers)}`); + + const transport = new SSEClientTransport(new URL(url), { + eventSourceInit: { + fetch: (url, init) => fetch(url, { ...init, headers }), + }, + requestInit: { + headers, + }, + }); await transport.start(); console.log("Connected to SSE transport"); @@ -82,7 +100,21 @@ app.get("/sse", async (req, res) => { try { console.log("New SSE connection"); - const backingServerTransport = await createTransport(req.query); + let backingServerTransport; + try { + backingServerTransport = await createTransport(req); + } catch (error) { + if (error instanceof SseError && error.code === 401) { + console.error( + "Received 401 Unauthorized from MCP server:", + error.message, + ); + res.status(401).json(error); + return; + } + + throw error; + } console.log("Connected MCP client to backing server transport"); @@ -109,9 +141,6 @@ app.get("/sse", async (req, res) => { mcpProxy({ transportToClient: webAppTransport, transportToServer: backingServerTransport, - onerror: (error) => { - console.error(error); - }, }); console.log("Set up MCP proxy"); @@ -152,4 +181,16 @@ app.get("/config", (req, res) => { }); const PORT = process.env.PORT || 3000; -app.listen(PORT, () => {}); + +try { + const server = app.listen(PORT); + + server.on("listening", () => { + const addr = server.address(); + const port = typeof addr === "string" ? addr : addr?.port; + console.log(`Proxy server listening on port ${port}`); + }); +} catch (error) { + console.error("Failed to start server:", error); + process.exit(1); +} diff --git a/server/src/mcpProxy.ts b/server/src/mcpProxy.ts index 7932845..b93c0cd 100644 --- a/server/src/mcpProxy.ts +++ b/server/src/mcpProxy.ts @@ -1,23 +1,29 @@ import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +function onClientError(error: Error) { + console.error("Error from inspector client:", error); +} + +function onServerError(error: Error) { + console.error("Error from MCP server:", error); +} + export default function mcpProxy({ transportToClient, transportToServer, - onerror, }: { transportToClient: Transport; transportToServer: Transport; - onerror: (error: Error) => void; }) { let transportToClientClosed = false; let transportToServerClosed = false; transportToClient.onmessage = (message) => { - transportToServer.send(message).catch(onerror); + transportToServer.send(message).catch(onServerError); }; transportToServer.onmessage = (message) => { - transportToClient.send(message).catch(onerror); + transportToClient.send(message).catch(onClientError); }; transportToClient.onclose = () => { @@ -26,7 +32,7 @@ export default function mcpProxy({ } transportToClientClosed = true; - transportToServer.close().catch(onerror); + transportToServer.close().catch(onServerError); }; transportToServer.onclose = () => { @@ -34,10 +40,9 @@ export default function mcpProxy({ return; } transportToServerClosed = true; - - transportToClient.close().catch(onerror); + transportToClient.close().catch(onClientError); }; - transportToClient.onerror = onerror; - transportToServer.onerror = onerror; + transportToClient.onerror = onClientError; + transportToServer.onerror = onServerError; }