diff --git a/.github/workflows/e2e_tests.yml b/.github/workflows/e2e_tests.yml index 02fdbc22..63409825 100644 --- a/.github/workflows/e2e_tests.yml +++ b/.github/workflows/e2e_tests.yml @@ -1,10 +1,14 @@ name: Playwright Tests +# Pinned to `v1/main`, this branch's own base. These filters read `main` +# historically, from when the v1 line was itself `main` — which meant they +# matched nothing here and the suite silently never ran on this branch. +# See #2065. on: push: - branches: [main] + branches: [v1/main] pull_request: - branches: [main] + branches: [v1/main] jobs: test: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8d7405c..ed6e7284 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,9 +1,18 @@ +# `v1/main` is the deprecated maintenance line: it publishes straight from this +# branch to the `v1-latest` dist-tag and never merges into `main`, which now +# carries v2. Keep these filters pinned to `v1/main` — a stale `main` entry here +# describes a topology that no longer exists, and would start matching the moment +# anything put a v1-shaped tree back on that branch. +# +# (For `push`, GitHub runs the workflow file from the pushed commit, so this file +# is only ever consulted for `v1/main` regardless. The filter states intent.) on: push: branches: - - main - + - v1/main pull_request: + branches: + - v1/main release: types: [published] @@ -37,6 +46,10 @@ jobs: working-directory: ./client run: npm test + - name: Run server tests + working-directory: ./server + run: npm test + - run: npm run build publish: @@ -65,6 +78,15 @@ jobs: - name: Ensure npm CLI supports OIDC trusted publishing run: npm install -g npm@^11.5.1 + # `publish-all` pins `--tag v1-latest` on all four packages. + # + # Do NOT drop the flag: without an explicit tag npm assigns `latest`, so a + # v1 security release would drag `latest` back from v2 and every + # `npx @modelcontextprotocol/inspector` would silently get deprecated v1. + # + # Do NOT "simplify" the tag to `v1`: npm rejects any dist-tag that parses + # as a SemVer range, and `v1` does (`npm error Tag name must not be a + # valid SemVer range: v1`). That would fail the publish outright. - run: npm run publish-all env: NPM_CONFIG_PROVENANCE: "true" diff --git a/AGENTS.md b/AGENTS.md index 27d28f72..3b8fef4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,35 +1,13 @@ # MCP Inspector Development Guide -> **Note:** Inspector V2 is under development to address architectural and UX improvements. During this time, V1 contributions should focus on **bug fixes and MCP spec compliance**. See [CONTRIBUTING.md](CONTRIBUTING.md) for more details. - -## Build Commands - -- Build all: `npm run build` -- Build client: `npm run build-client` -- Build server: `npm run build-server` -- Development mode: `npm run dev` (use `npm run dev:windows` on Windows) -- Format code: `npm run prettier-fix` -- Client lint: `cd client && npm run lint` - -## Code Style Guidelines - -- Use TypeScript with proper type annotations -- Follow React functional component patterns with hooks -- Use ES modules (import/export) not CommonJS -- Use Prettier for formatting (auto-formatted on commit) -- Follow existing naming conventions: - - camelCase for variables and functions - - PascalCase for component names and types - - kebab-case for file names -- Use async/await for asynchronous operations -- Implement proper error handling with try/catch blocks -- Use Tailwind CSS for styling in the client -- Keep components small and focused on a single responsibility - -## Project Organization - -The project is organized as a monorepo with workspaces: - -- `client/`: React frontend with Vite, TypeScript and Tailwind -- `server/`: Express backend with TypeScript -- `cli/`: Command-line interface for testing and invoking MCP server methods directly +> # ⛔ v1 is deprecated — do not open pull requests against this branch +> +> **This branch (`v1/main`) accepts security fixes only, and only by way of +> issue creation.** +> +> **Agents must NEVER attempt to create a pull request against `v1/main`.** File +> an issue describing the security problem instead. A human maintainer decides +> whether it warrants a v1 release and implements it. +> +> Everything else — features, bug fixes, refactors, docs — belongs in **v2**. +> Do not port it here, and do not "helpfully" fix it here in passing. diff --git a/README.md b/README.md index 36e9f3de..d185cf3d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,17 @@ 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) +> # ⚠️ Deprecated +> +> **`@modelcontextprotocol/inspector` v1 is deprecated.** +> +> ``` +> npx @modelcontextprotocol/inspector@latest +> ``` +> +> v1 receives **security fixes only**. All new development happens in v2. + +![MCP Inspector Screenshot](https://raw.githubusercontent.com/modelcontextprotocol/inspector/v1/main/mcp-inspector.png) ## Architecture Overview diff --git a/cli/package.json b/cli/package.json index 81f0ac68..4530478a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-cli", - "version": "1.0.0", + "version": "1.0.2", "description": "CLI for the Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", diff --git a/cli/src/cli.ts b/cli/src/cli.ts index f4187e02..f625ed01 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -9,6 +9,20 @@ import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); +/** + * v1 deprecation notice. Written to stderr, never stdout: `--cli` mode emits + * JSON on stdout and callers pipe it, so a banner there would corrupt output. + */ +const DEPRECATION_NOTICE = [ + "⚠️ @modelcontextprotocol/inspector v1 is deprecated.", + " Upgrade: npx @modelcontextprotocol/inspector@latest", + " v1 receives security fixes only.", +].join("\n"); + +function printDeprecationNotice(): void { + console.error(`\n${DEPRECATION_NOTICE}\n`); +} + type Args = { command: string; args: string[]; @@ -378,6 +392,8 @@ async function main(): Promise { handleError(error); }); + printDeprecationNotice(); + try { const args = parseArgs(); diff --git a/client/README.md b/client/README.md index 780c92d8..2516eb39 100644 --- a/client/README.md +++ b/client/README.md @@ -1,3 +1,13 @@ +> # ⚠️ Deprecated +> +> **`@modelcontextprotocol/inspector` v1 is deprecated.** +> +> ``` +> npx @modelcontextprotocol/inspector@latest +> ``` +> +> v1 receives **security fixes only**. All new development happens in v2. + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. diff --git a/client/bin/start.js b/client/bin/start.js index 7e7e013a..15ebd358 100755 --- a/client/bin/start.js +++ b/client/bin/start.js @@ -167,6 +167,11 @@ async function startDevClient(clientOptions) { // Give vite time to start before opening or logging the URL setTimeout(() => { + console.log( + `\n⚠️ @modelcontextprotocol/inspector v1 is deprecated.\n` + + ` Upgrade: npx @modelcontextprotocol/inspector@latest\n` + + ` v1 receives security fixes only.`, + ); console.log(`\n🚀 MCP Inspector is up and running at:\n ${url}\n`); if (process.env.MCP_AUTO_OPEN_ENABLED !== "false") { console.log("🌐 Opening browser..."); diff --git a/client/package.json b/client/package.json index fc0d9194..2342da68 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-client", - "version": "1.0.0", + "version": "1.0.2", "description": "Client-side application for the Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", diff --git a/client/src/App.tsx b/client/src/App.tsx index 7cf6d751..ffc95a30 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -69,6 +69,7 @@ import { z } from "zod"; import "./App.css"; import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; +import DeprecationBanner from "./components/DeprecationBanner"; import HistoryAndNotifications from "./components/HistoryAndNotifications"; import PingTab from "./components/PingTab"; import PromptsTab, { Prompt } from "./components/PromptsTab"; @@ -1367,429 +1368,434 @@ const App = () => { } return ( -
-
- +
+ +
-
-
-
- {mcpClient ? ( - { - setActiveTab(value); - window.location.hash = value; - }} - > - - - - Resources - - - - Prompts - - - - Tools - - - - Tasks - - - - Apps - - - - Ping - - - - Sampling - {pendingSampleRequests.length > 0 && ( - - {pendingSampleRequests.length} - - )} - - - - Elicitations - {pendingElicitationRequests.length > 0 && ( - - {pendingElicitationRequests.length} - - )} - - - - Roots - - - - Auth - - - - Metadata - - - -
- {!serverCapabilities?.resources && - !serverCapabilities?.prompts && - !serverCapabilities?.tools ? ( - <> -
-

- The connected server does not support any MCP - capabilities -

-
- { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - ) : ( - <> - { - clearError("resources"); - listResources(); - }} - clearResources={() => { - setResources([]); - setNextResourceCursor(undefined); - }} - listResourceTemplates={() => { - clearError("resources"); - listResourceTemplates(); - }} - clearResourceTemplates={() => { - setResourceTemplates([]); - setNextResourceTemplateCursor(undefined); - }} - readResource={(uri, options) => { - clearError("resources"); - readResource(uri, options); - }} - selectedResource={selectedResource} - setSelectedResource={(resource) => { - clearError("resources"); - setSelectedResource(resource); - }} - resourceSubscriptionsSupported={ - serverCapabilities?.resources?.subscribe || false - } - resourceSubscriptions={resourceSubscriptions} - subscribeToResource={(uri) => { - clearError("resources"); - subscribeToResource(uri); - }} - unsubscribeFromResource={(uri) => { - clearError("resources"); - unsubscribeFromResource(uri); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - resourceContent={resourceContent} - nextCursor={nextResourceCursor} - nextTemplateCursor={nextResourceTemplateCursor} - error={errors.resources} - /> - { - clearError("prompts"); - listPrompts(); - }} - clearPrompts={() => { - setPrompts([]); - setNextPromptCursor(undefined); - }} - getPrompt={(name, args) => { - clearError("prompts"); - getPrompt(name, args); - }} - selectedPrompt={selectedPrompt} - setSelectedPrompt={(prompt) => { - clearError("prompts"); - setSelectedPrompt(prompt); - setPromptContent(""); - }} - handleCompletion={handleCompletion} - completionsSupported={completionsSupported} - promptContent={promptContent} - nextCursor={nextPromptCursor} - error={errors.prompts} - /> - { - clearError("tools"); - listTools(); - }} - clearTools={() => { - setTools([]); - setNextToolCursor(undefined); - cacheToolOutputSchemas([]); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - const result = await callTool( - name, - params, - metadata, - runAsTask, - ); - const calledTool = tools.find( - (tool) => tool.name === name, - ); - if (calledTool && hasAppResourceUri(calledTool)) { - setPrefilledAppsToolCall({ - id: ++prefilledAppsToolCallIdRef.current, - toolName: name, - params: cloneToolParams(params), - result, - }); - } else { - setPrefilledAppsToolCall(null); - } - return result; - }} - selectedTool={selectedTool} - setSelectedTool={(tool) => { - clearError("tools"); - setSelectedTool(tool); - setToolResult(null); - }} - toolResult={toolResult} - isPollingTask={isPollingTask} - nextCursor={nextToolCursor} - error={errors.tools} - resourceContent={resourceContentMap} - resourceError={resourceErrorMap} - onReadResource={(uri: string) => { - clearError("resources"); - readResource(uri); - }} - /> - { - clearError("tasks"); - listTasks(); - }} - clearTasks={() => { - setTasks([]); - setNextTaskCursor(undefined); - }} - cancelTask={cancelTask} - selectedTask={selectedTask} - setSelectedTask={(task) => { - clearError("tasks"); - setSelectedTask(task); - }} - error={errors.tasks} - nextCursor={nextTaskCursor} - /> - { - clearError("tools"); - listTools(); - }} - callTool={async ( - name: string, - params: Record, - metadata?: Record, - runAsTask?: boolean, - ) => { - clearError("tools"); - setToolResult(null); - return callTool(name, params, metadata, runAsTask); - }} - prefilledToolCall={prefilledAppsToolCall} - onPrefilledToolCallConsumed={(callId) => { - setPrefilledAppsToolCall((prev) => - prev?.id === callId ? null : prev, - ); - }} - error={errors.tools} - mcpClient={mcpClient} - onNotification={(notification) => { - setNotifications((prev) => [...prev, notification]); - }} - /> - - { - void sendMCPRequest( - { - method: "ping" as const, - }, - EmptyResultSchema, - ); - }} - /> - - - - - - - )} -
-
- ) : isAuthDebuggerVisible ? ( - (window.location.hash = value)} - > - - - ) : ( -
-

- Connect to an MCP server to start inspecting -

-
-

- Need to configure authentication? -

- -
-
- )} -
-
+
-
+ onMouseDown={handleSidebarDragStart} + style={{ + cursor: "col-resize", + position: "absolute", + top: 0, + right: 0, + width: 6, + height: "100%", + zIndex: 10, + background: isSidebarDragging + ? "rgba(0,0,0,0.08)" + : "transparent", + }} + aria-label="Resize sidebar" + data-testid="sidebar-drag-handle" + /> +
+
+
+ {mcpClient ? ( + { + setActiveTab(value); + window.location.hash = value; + }} + > + + + + Resources + + + + Prompts + + + + Tools + + + + Tasks + + + + Apps + + + + Ping + + + + Sampling + {pendingSampleRequests.length > 0 && ( + + {pendingSampleRequests.length} + + )} + + + + Elicitations + {pendingElicitationRequests.length > 0 && ( + + {pendingElicitationRequests.length} + + )} + + + + Roots + + + + Auth + + + + Metadata + + + +
+ {!serverCapabilities?.resources && + !serverCapabilities?.prompts && + !serverCapabilities?.tools ? ( + <> +
+

+ The connected server does not support any MCP + capabilities +

+
+ { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + ) : ( + <> + { + clearError("resources"); + listResources(); + }} + clearResources={() => { + setResources([]); + setNextResourceCursor(undefined); + }} + listResourceTemplates={() => { + clearError("resources"); + listResourceTemplates(); + }} + clearResourceTemplates={() => { + setResourceTemplates([]); + setNextResourceTemplateCursor(undefined); + }} + readResource={(uri, options) => { + clearError("resources"); + readResource(uri, options); + }} + selectedResource={selectedResource} + setSelectedResource={(resource) => { + clearError("resources"); + setSelectedResource(resource); + }} + resourceSubscriptionsSupported={ + serverCapabilities?.resources?.subscribe || false + } + resourceSubscriptions={resourceSubscriptions} + subscribeToResource={(uri) => { + clearError("resources"); + subscribeToResource(uri); + }} + unsubscribeFromResource={(uri) => { + clearError("resources"); + unsubscribeFromResource(uri); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + resourceContent={resourceContent} + nextCursor={nextResourceCursor} + nextTemplateCursor={nextResourceTemplateCursor} + error={errors.resources} + /> + { + clearError("prompts"); + listPrompts(); + }} + clearPrompts={() => { + setPrompts([]); + setNextPromptCursor(undefined); + }} + getPrompt={(name, args) => { + clearError("prompts"); + getPrompt(name, args); + }} + selectedPrompt={selectedPrompt} + setSelectedPrompt={(prompt) => { + clearError("prompts"); + setSelectedPrompt(prompt); + setPromptContent(""); + }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} + promptContent={promptContent} + nextCursor={nextPromptCursor} + error={errors.prompts} + /> + { + clearError("tools"); + listTools(); + }} + clearTools={() => { + setTools([]); + setNextToolCursor(undefined); + cacheToolOutputSchemas([]); + }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + const result = await callTool( + name, + params, + metadata, + runAsTask, + ); + const calledTool = tools.find( + (tool) => tool.name === name, + ); + if (calledTool && hasAppResourceUri(calledTool)) { + setPrefilledAppsToolCall({ + id: ++prefilledAppsToolCallIdRef.current, + toolName: name, + params: cloneToolParams(params), + result, + }); + } else { + setPrefilledAppsToolCall(null); + } + return result; + }} + selectedTool={selectedTool} + setSelectedTool={(tool) => { + clearError("tools"); + setSelectedTool(tool); + setToolResult(null); + }} + toolResult={toolResult} + isPollingTask={isPollingTask} + nextCursor={nextToolCursor} + error={errors.tools} + resourceContent={resourceContentMap} + resourceError={resourceErrorMap} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} + /> + { + clearError("tasks"); + listTasks(); + }} + clearTasks={() => { + setTasks([]); + setNextTaskCursor(undefined); + }} + cancelTask={cancelTask} + selectedTask={selectedTask} + setSelectedTask={(task) => { + clearError("tasks"); + setSelectedTask(task); + }} + error={errors.tasks} + nextCursor={nextTaskCursor} + /> + { + clearError("tools"); + listTools(); + }} + callTool={async ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => { + clearError("tools"); + setToolResult(null); + return callTool(name, params, metadata, runAsTask); + }} + prefilledToolCall={prefilledAppsToolCall} + onPrefilledToolCallConsumed={(callId) => { + setPrefilledAppsToolCall((prev) => + prev?.id === callId ? null : prev, + ); + }} + error={errors.tools} + mcpClient={mcpClient} + onNotification={(notification) => { + setNotifications((prev) => [...prev, notification]); + }} + /> + + { + void sendMCPRequest( + { + method: "ping" as const, + }, + EmptyResultSchema, + ); + }} + /> + + + + + + + )} +
+
+ ) : isAuthDebuggerVisible ? ( + (window.location.hash = value)} + > + + + ) : ( +
+

+ Connect to an MCP server to start inspecting +

+
+

+ Need to configure authentication? +

+ +
+
+ )}
-
- +
+
+
+
+
+ +
diff --git a/client/src/components/DeprecationBanner.tsx b/client/src/components/DeprecationBanner.tsx new file mode 100644 index 00000000..c322b19d --- /dev/null +++ b/client/src/components/DeprecationBanner.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { X } from "lucide-react"; + +const DISMISSED_KEY = "mcp-inspector-v1-deprecation-dismissed"; + +/** + * v1 deprecation notice shown at the top of the app. + * + * Dismissal is persisted in localStorage so it stays hidden across reloads. + * A storage failure (Safari private mode, disabled storage) must not break the + * app, so both reads and writes are guarded — the banner simply reappears. + */ +const DeprecationBanner = () => { + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } + }); + + if (dismissed) { + return null; + } + + const handleDismiss = () => { + try { + localStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // Ignore: dismissal just won't persist across reloads. + } + setDismissed(true); + }; + + return ( +
+

+ + ⚠️ MCP Inspector v1 is deprecated. + {" "} + Upgrade with{" "} + + npx @modelcontextprotocol/inspector@latest + + . v1 receives security fixes only. +

+ +
+ ); +}; + +export default DeprecationBanner; diff --git a/mcp-inspector.png b/mcp-inspector.png index 4217cf14..1be86e61 100644 Binary files a/mcp-inspector.png and b/mcp-inspector.png differ diff --git a/package-lock.json b/package-lock.json index d70bf35a..0d8ec074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "1.0.0", + "version": "1.0.2", "license": "SEE LICENSE IN LICENSE", "workspaces": [ "client", @@ -14,9 +14,9 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^1.0.0", - "@modelcontextprotocol/inspector-client": "^1.0.0", - "@modelcontextprotocol/inspector-server": "^1.0.0", + "@modelcontextprotocol/inspector-cli": "^1.0.2", + "@modelcontextprotocol/inspector-client": "^1.0.2", + "@modelcontextprotocol/inspector-server": "^1.0.2", "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", "node-fetch": "^3.3.2", @@ -48,7 +48,7 @@ }, "cli": { "name": "@modelcontextprotocol/inspector-cli", - "version": "1.0.0", + "version": "1.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", @@ -74,7 +74,7 @@ }, "client": { "name": "@modelcontextprotocol/inspector-client", - "version": "1.0.0", + "version": "1.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@mcp-ui/client": "^6.0.0", @@ -1612,9 +1612,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -1731,9 +1731,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", "dev": true, "license": "MIT", "dependencies": { @@ -4538,16 +4538,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -5223,9 +5223,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6785,9 +6785,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -7281,9 +7281,9 @@ } }, "node_modules/hono": { - "version": "4.12.30", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.30.tgz", - "integrity": "sha512-emn+JoJjrN9YTpRDS5it/UI2SO9BAE37T6I3d963RxcZ81G9A4pr2SZTEiiaiKbzx+NKRg5BZ89fCL7gCJCUog==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", + "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -7494,9 +7494,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -8949,9 +8949,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -9774,9 +9774,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -10371,9 +10371,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -10391,7 +10391,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11108,16 +11108,16 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/rimraf/node_modules/glob": { @@ -13254,7 +13254,7 @@ }, "server": { "name": "@modelcontextprotocol/inspector-server", - "version": "1.0.0", + "version": "1.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", @@ -13273,10 +13273,12 @@ "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", + "@types/node": "^22.0.0", "@types/shell-quote": "^1.7.5", "@types/ws": "^8.5.12", "tsx": "^4.19.0", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "vitest": "^4.1.0" } } } diff --git a/package.json b/package.json index 42d3fb90..7978e9c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "1.0.0", + "version": "1.0.2", "description": "Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", @@ -42,19 +42,20 @@ "start-client": "cd client && npm run preview", "test": "npm run prettier-check && cd client && npm test", "test-cli": "cd cli && npm run test", + "test-server": "cd server && npm run test", "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client", "prettier-fix": "prettier --write .", "prettier-check": "prettier --check .", "lint": "prettier --check . && cd client && npm run lint", "prepare": "husky && npm run build", - "publish-all": "npm publish --workspaces --access public && npm publish --access public", + "publish-all": "npm publish --workspaces --access public --tag v1-latest && npm publish --access public --tag v1-latest", "update-version": "node scripts/update-version.js", "check-version": "node scripts/check-version-consistency.js" }, "dependencies": { - "@modelcontextprotocol/inspector-cli": "^1.0.0", - "@modelcontextprotocol/inspector-client": "^1.0.0", - "@modelcontextprotocol/inspector-server": "^1.0.0", + "@modelcontextprotocol/inspector-cli": "^1.0.2", + "@modelcontextprotocol/inspector-client": "^1.0.2", + "@modelcontextprotocol/inspector-server": "^1.0.2", "@modelcontextprotocol/sdk": "^1.25.2", "concurrently": "^9.2.0", "node-fetch": "^3.3.2", @@ -80,13 +81,20 @@ "overrides": { "get-intrinsic": "1.3.0", "shell-quote": "^1.8.4", - "hono": "^4.12.25", + "hono": "^4.13.3", "form-data": "^4.0.6", "@babel/core@<=7.29.0": "^7.29.6", "esbuild@<0.28.1": "^0.28.1", "minimatch@<3.1.4": "3.1.5", - "js-yaml@<4": "3.15.0", - "js-yaml@>=4": "4.2.0" + "js-yaml@<4": "3.15.1", + "js-yaml@>=4": "4.3.1", + "@hono/node-server@<1.19.15": "^1.19.15", + "ip-address@<=10.3.0": "^10.5.0", + "fast-uri@<3.1.5": "^3.1.5", + "brace-expansion@<2": "^1.1.18", + "brace-expansion@>=4": "^5.0.9", + "nanoid@<3.3.18": "^3.3.18", + "postcss@<=8.5.22": "^8.5.26" }, "engines": { "node": ">=22.7.5" @@ -95,5 +103,9 @@ "**/*.{js,ts,jsx,tsx,json,md}": [ "prettier --write" ] + }, + "allowScripts": { + "esbuild": false, + "fsevents": false } } diff --git a/server/package.json b/server/package.json index 8137a69f..0d60067f 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector-server", - "version": "1.0.0", + "version": "1.0.2", "description": "Server-side application for the Model Context Protocol inspector", "license": "SEE LICENSE IN LICENSE", "author": "Model Context Protocol a Series of LF Projects, LLC.", @@ -24,15 +24,19 @@ "build": "tsc && shx cp -R static build", "start": "node build/index.js", "dev": "tsx watch --clear-screen=false src/index.ts", - "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL" + "dev:windows": "tsx watch --clear-screen=false src/index.ts < NUL", + "test": "tsc -p tsconfig.test.json && vitest run", + "test:watch": "vitest" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", + "@types/node": "^22.0.0", "@types/shell-quote": "^1.7.5", "@types/ws": "^8.5.12", "tsx": "^4.19.0", - "typescript": "^5.6.2" + "typescript": "^5.6.2", + "vitest": "^4.1.0" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.2", diff --git a/server/src/__tests__/proxy-security.test.ts b/server/src/__tests__/proxy-security.test.ts new file mode 100644 index 00000000..db97e6b9 --- /dev/null +++ b/server/src/__tests__/proxy-security.test.ts @@ -0,0 +1,359 @@ +/** + * Unit tests for proxy-security.ts + * + * Tests cover: + * - isBlockedProxyAddress: IPv4, IPv6, IPv4-mapped IPv6 (hex + dotted), edge cases + * - assertSafeProxyTarget: safe IPs, blocked IPs, literal-IP hosts, DNS errors + * - createPinnedAgent: correct agent type, lookup always returns pinned IP + * - TOCTOU guarantee: the pinned agent never invokes the OS resolver + */ + +import http from "node:http"; +import https from "node:https"; +import type dnsTypes from "node:dns"; +import type { AddressInfo } from "node:net"; +import nodeFetch from "node-fetch"; +import { vi, describe, it, expect, afterEach } from "vitest"; + +// Mock node:dns/promises before importing the module under test so that +// assertSafeProxyTarget's dnsLookup is replaceable in each test. +vi.mock("node:dns/promises", () => ({ + lookup: vi.fn(), +})); + +import * as dns from "node:dns/promises"; +import { + isBlockedProxyAddress, + assertSafeProxyTarget, + createPinnedAgent, + ProxyTargetError, +} from "../proxy-security.js"; + +// Convenience cast — vitest doesn't know the mock shape yet. +const mockLookup = dns.lookup as ReturnType; + +afterEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// isBlockedProxyAddress +// --------------------------------------------------------------------------- + +describe("isBlockedProxyAddress", () => { + describe("IPv4 link-local (169.254.0.0/16)", () => { + it("blocks 169.254.169.254 (AWS metadata)", () => { + expect(isBlockedProxyAddress("169.254.169.254")).toBe(true); + }); + + it("blocks 169.254.0.1 (first address in range)", () => { + expect(isBlockedProxyAddress("169.254.0.1")).toBe(true); + }); + + it("blocks 169.254.255.255 (last address in range)", () => { + expect(isBlockedProxyAddress("169.254.255.255")).toBe(true); + }); + + it("allows 169.253.0.1 (just outside the range)", () => { + expect(isBlockedProxyAddress("169.253.0.1")).toBe(false); + }); + + it("allows 170.254.0.1 (just outside the range)", () => { + expect(isBlockedProxyAddress("170.254.0.1")).toBe(false); + }); + + it("allows loopback 127.0.0.1", () => { + expect(isBlockedProxyAddress("127.0.0.1")).toBe(false); + }); + + it("allows a public IP", () => { + expect(isBlockedProxyAddress("93.184.216.34")).toBe(false); + }); + }); + + describe("IPv6 link-local (fe80::/10)", () => { + it("blocks fe80::1", () => { + expect(isBlockedProxyAddress("fe80::1")).toBe(true); + }); + + it("blocks fe80::aabb:ccdd (arbitrary link-local)", () => { + expect(isBlockedProxyAddress("fe80::aabb:ccdd")).toBe(true); + }); + + it("allows ::1 (loopback)", () => { + expect(isBlockedProxyAddress("::1")).toBe(false); + }); + + it("allows 2001:db8::1 (documentation range)", () => { + expect(isBlockedProxyAddress("2001:db8::1")).toBe(false); + }); + }); + + describe("AWS IPv6 IMDS (fd00:ec2::254)", () => { + it("blocks fd00:ec2::254 exactly", () => { + expect(isBlockedProxyAddress("fd00:ec2::254")).toBe(true); + }); + + it("allows fd00:ec2::255 (adjacent address)", () => { + expect(isBlockedProxyAddress("fd00:ec2::255")).toBe(false); + }); + }); + + describe("IPv4-mapped IPv6 variants of 169.254.169.254", () => { + it("blocks dotted form ::ffff:169.254.169.254", () => { + expect(isBlockedProxyAddress("::ffff:169.254.169.254")).toBe(true); + }); + + it("blocks hex form ::ffff:a9fe:a9fe (WHATWG URL serialization)", () => { + expect(isBlockedProxyAddress("::ffff:a9fe:a9fe")).toBe(true); + }); + + it("allows IPv4-mapped loopback ::ffff:127.0.0.1", () => { + expect(isBlockedProxyAddress("::ffff:127.0.0.1")).toBe(false); + }); + }); + + describe("non-IP strings", () => { + it("allows empty string (not an IP)", () => { + expect(isBlockedProxyAddress("")).toBe(false); + }); + + it("allows hostname string (not an IP)", () => { + expect(isBlockedProxyAddress("example.com")).toBe(false); + }); + }); +}); + +// --------------------------------------------------------------------------- +// assertSafeProxyTarget +// --------------------------------------------------------------------------- + +describe("assertSafeProxyTarget", () => { + it("resolves and allows a safe hostname", async () => { + mockLookup.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + + const addrs = await assertSafeProxyTarget(new URL("http://example.com/")); + expect(addrs).toEqual(["93.184.216.34"]); + expect(mockLookup).toHaveBeenCalledWith("example.com", { all: true }); + }); + + it("throws ProxyTargetError when host resolves to blocked IP", async () => { + mockLookup.mockResolvedValueOnce([ + { address: "169.254.169.254", family: 4 }, + ]); + + await expect( + assertSafeProxyTarget(new URL("http://evil.example.com/")), + ).rejects.toThrow(ProxyTargetError); + }); + + it("throws ProxyTargetError when any resolved IP is blocked (mixed results)", async () => { + mockLookup.mockResolvedValueOnce([ + { address: "93.184.216.34", family: 4 }, + { address: "169.254.169.254", family: 4 }, + ]); + + await expect( + assertSafeProxyTarget(new URL("http://dual.example.com/")), + ).rejects.toThrow(ProxyTargetError); + }); + + it("throws ProxyTargetError when DNS lookup fails", async () => { + mockLookup.mockRejectedValueOnce(new Error("ENOTFOUND")); + + await expect( + assertSafeProxyTarget(new URL("http://nonexistent.invalid/")), + ).rejects.toThrow(ProxyTargetError); + }); + + it("skips DNS lookup for literal IPv4 hosts", async () => { + const addrs = await assertSafeProxyTarget(new URL("http://127.0.0.1/path")); + expect(addrs).toEqual(["127.0.0.1"]); + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("throws ProxyTargetError for literal blocked IPv4", async () => { + await expect( + assertSafeProxyTarget(new URL("http://169.254.169.254/")), + ).rejects.toThrow(ProxyTargetError); + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("skips DNS lookup for literal IPv6 hosts", async () => { + const addrs = await assertSafeProxyTarget(new URL("http://[::1]/")); + expect(addrs).toEqual(["::1"]); + expect(mockLookup).not.toHaveBeenCalled(); + }); + + it("returns all validated addresses so caller can pick one for pinning", async () => { + mockLookup.mockResolvedValueOnce([ + { address: "192.0.2.1", family: 4 }, + { address: "192.0.2.2", family: 4 }, + ]); + + const addrs = await assertSafeProxyTarget(new URL("http://multi.example/")); + expect(addrs).toHaveLength(2); + expect(addrs).toContain("192.0.2.1"); + expect(addrs).toContain("192.0.2.2"); + }); +}); + +// --------------------------------------------------------------------------- +// createPinnedAgent +// --------------------------------------------------------------------------- + +/** The lookup hook `createPinnedAgent` installed on the agent. */ +type PinnedLookup = ( + hostname: string, + options: dnsTypes.LookupOptions, + callback: ( + err: NodeJS.ErrnoException | null, + address: string | dnsTypes.LookupAddress[], + family?: number, + ) => void, +) => void; + +function pinnedLookup(agent: http.Agent | https.Agent): PinnedLookup { + const { lookup } = (agent as http.Agent & { options: http.AgentOptions }) + .options; + if (typeof lookup !== "function") { + throw new Error("expected createPinnedAgent to install a lookup hook"); + } + return lookup as PinnedLookup; +} + +describe("createPinnedAgent", () => { + it("returns an http.Agent for http: protocol", () => { + const agent = createPinnedAgent("http:", ["127.0.0.1"]); + expect(agent).toBeInstanceOf(http.Agent); + expect(agent).not.toBeInstanceOf(https.Agent); + }); + + it("returns an https.Agent for https: protocol", () => { + const agent = createPinnedAgent("https:", ["127.0.0.1"]); + expect(agent).toBeInstanceOf(https.Agent); + }); + + it("pinned lookup returns the IPv4 address regardless of queried hostname", () => { + const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"])); + + const callback = vi.fn(); + lookup("example.com", {}, callback); + + expect(callback).toHaveBeenCalledWith(null, "192.0.2.99", 4); + }); + + it("pinned lookup returns the IPv6 address and family 6", () => { + const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"])); + + const callback = vi.fn(); + lookup("example.com", {}, callback); + + expect(callback).toHaveBeenCalledWith(null, "2001:db8::1", 6); + }); + + // Node's net.connect runs with autoSelectFamily on (Node >= 20), so it calls + // the hook with { all: true } and requires the ARRAY callback shape. Handing + // it a scalar there fails every hostname request with ERR_INVALID_IP_ADDRESS. + it("pinned lookup returns the array shape when called with { all: true }", () => { + const lookup = pinnedLookup(createPinnedAgent("http:", ["192.0.2.99"])); + + const callback = vi.fn(); + lookup("example.com", { all: true }, callback); + + expect(callback).toHaveBeenCalledWith(null, [ + { address: "192.0.2.99", family: 4 }, + ]); + }); + + it("pinned lookup returns the array shape for IPv6 too", () => { + const lookup = pinnedLookup(createPinnedAgent("http:", ["2001:db8::1"])); + + const callback = vi.fn(); + lookup("example.com", { all: true }, callback); + + expect(callback).toHaveBeenCalledWith(null, [ + { address: "2001:db8::1", family: 6 }, + ]); + }); + + // Every validated address is handed to the hook, so Node keeps its normal + // address-family fallback for a dual-stack host whose first address is + // unreachable — while still only ever seeing prevalidated addresses. + it("passes every validated address through for dual-stack fallback", () => { + const lookup = pinnedLookup( + createPinnedAgent("http:", ["2001:db8::1", "192.0.2.7"]), + ); + + const callback = vi.fn(); + lookup("dual.example", { all: true }, callback); + + expect(callback).toHaveBeenCalledWith(null, [ + { address: "2001:db8::1", family: 6 }, + { address: "192.0.2.7", family: 4 }, + ]); + }); + + it("throws when given no validated address", () => { + expect(() => createPinnedAgent("http:", [])).toThrow(ProxyTargetError); + }); + + // A dual-stack host whose first (IPv6) address is dead: the connection must + // still complete via the second, validated address. + it("falls back to a later validated address when the first is unreachable", async () => { + const server = http.createServer((_req, res) => res.end("fallback-ok")); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const { port } = server.address() as AddressInfo; + + try { + // 2001:db8::1 is the documentation range — nothing answers there. + const agent = createPinnedAgent("http:", ["2001:db8::1", "127.0.0.1"]); + const response = await nodeFetch(`http://dual.invalid:${port}/`, { + agent, + }); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("fallback-ok"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("TOCTOU guarantee: lookup never invokes the OS resolver", () => { + const lookup = pinnedLookup(createPinnedAgent("http:", ["10.0.0.1"])); + + const callback = vi.fn(); + lookup("any-hostname.example", {}, callback); + + // The callback is invoked synchronously with the fixed IP — no resolver. + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(null, "10.0.0.1", 4); + }); + + // End-to-end over the production call path: node-fetch → http.Agent → + // net.connect. The hostname is deliberately unresolvable, so the request can + // only succeed if the connection went to the pinned IP, and it exercises the + // real { all: true } callback shape rather than a hand-rolled invocation. + it("routes a real request to the pinned IP for an unresolvable hostname", async () => { + const server = http.createServer((_req, res) => res.end("pinned-ok")); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve()), + ); + const { port } = server.address() as AddressInfo; + + try { + const agent = createPinnedAgent("http:", ["127.0.0.1"]); + const response = await nodeFetch( + `http://pinned-target.invalid:${port}/`, + { agent }, + ); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("pinned-ok"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 4c86bd79..affe607b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,6 +4,11 @@ import cors from "cors"; import { parseArgs } from "node:util"; import { parse as shellParseArgs } from "shell-quote"; import nodeFetch, { Headers as NodeHeaders } from "node-fetch"; +import { + assertSafeProxyTarget, + createPinnedAgent, + ProxyTargetError, +} from "./proxy-security.js"; // Type-compatible wrappers for node-fetch to work with browser-style types const fetch = nodeFetch; @@ -29,8 +34,6 @@ import rateLimit from "express-rate-limit"; import { findActualExecutable } from "spawn-rx"; import mcpProxy, { type ProxyHeaderHolder } from "./mcpProxy.js"; import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto"; -import { lookup as dnsLookup } from "node:dns/promises"; -import { isIP } from "node:net"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { readFileSync } from "fs"; @@ -863,91 +866,17 @@ app.get("/health", (req, res) => { }); }); -// Raised when the /fetch proxy is asked to reach a disallowed target, so the -// route can distinguish an SSRF rejection (403) from an upstream failure (500). -class ProxyTargetError extends Error {} - const MAX_PROXY_REDIRECTS = 5; -// The /fetch proxy is used by the client to reach OAuth discovery/token -// endpoints on the connected MCP server (bypassing browser CORS). Connecting -// to loopback and RFC1918/private LAN hosts is a core use case (testing local -// or self-hosted MCP servers), so those stay reachable. We only block -// link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata -// endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS -// address — which are never legitimate proxy targets and are the real SSRF -// exfiltration vector. -function isBlockedProxyAddress(ip: string): boolean { - const kind = isIP(ip); - if (kind === 4) { - const parts = ip.split(".").map(Number); - // 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254) - return parts[0] === 169 && parts[1] === 254; - } - if (kind === 6) { - const addr = ip.toLowerCase(); - // IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded - // IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other - // sources may use the dotted form (::ffff:169.254.169.254) — handle both by - // extracting the IPv4 and re-checking it. - const mapped = addr.match(/^::ffff:(.+)$/); - if (mapped) { - const tail = mapped[1]; - if (isIP(tail) === 4) { - return isBlockedProxyAddress(tail); - } - const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); - if (hex) { - const high = parseInt(hex[1], 16); - const low = parseInt(hex[2], 16); - return isBlockedProxyAddress( - `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`, - ); - } - } - // fe80::/10 — IPv6 link-local - if (/^fe[89ab]/.test(addr)) { - return true; - } - // fd00:ec2::254 — AWS IPv6 instance metadata endpoint - return addr === "fd00:ec2::254"; - } - return false; -} - -// Resolves the target host and rejects it if any resolved address is a blocked -// (link-local/metadata) address, mitigating SSRF including the DNS case where a -// hostname resolves to the cloud metadata IP. -async function assertSafeProxyTarget(parsedUrl: URL): Promise { - const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets - - let addresses: string[]; - if (isIP(host)) { - addresses = [host]; - } else { - try { - addresses = (await dnsLookup(host, { all: true })).map((r) => r.address); - } catch { - throw new ProxyTargetError(`Could not resolve host: ${host}`); - } - } - - if (addresses.length === 0) { - throw new ProxyTargetError(`Could not resolve host: ${host}`); - } - - for (const address of addresses) { - if (isBlockedProxyAddress(address)) { - throw new ProxyTargetError( - `Refusing to proxy request to blocked address (${address})`, - ); - } - } -} - // Fetch that follows redirects manually so every hop's target is re-validated // against assertSafeProxyTarget — otherwise an allowed host could redirect the // proxy into a blocked internal/metadata address. +// +// On each hop we call assertSafeProxyTarget (which resolves DNS and validates +// every returned IP) and then pass a createPinnedAgent to node-fetch so it +// connects to the IP we just validated instead of re-resolving the hostname. +// This eliminates the TOCTOU window between the block-list check and the +// actual TCP connection that a DNS-rebinding attack would exploit. async function safeProxyFetch(initialUrl: string, init?: RequestInit) { let currentUrl = new URL(initialUrl); let method = init?.method ?? "GET"; @@ -958,13 +887,20 @@ async function safeProxyFetch(initialUrl: string, init?: RequestInit) { if (!["http:", "https:"].includes(currentUrl.protocol)) { throw new ProxyTargetError("Only http/https URLs are allowed"); } - await assertSafeProxyTarget(currentUrl); + + // Resolve DNS once, validate every address, and pin the connection to that + // validated set so node-fetch never does a second DNS lookup. All of them + // are passed through, so Node's address-family fallback still works while + // the reachable set stays limited to what was just validated. + const validatedAddresses = await assertSafeProxyTarget(currentUrl); + const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses); const response = await fetch(currentUrl.toString(), { method, headers, body, redirect: "manual", + agent, }); if (response.status >= 300 && response.status < 400) { @@ -1088,6 +1024,11 @@ const HOST = process.env.HOST || "localhost"; const server = app.listen(PORT, HOST); server.on("listening", () => { + console.log( + `\n⚠️ @modelcontextprotocol/inspector v1 is deprecated.\n` + + ` Upgrade: npx @modelcontextprotocol/inspector@latest\n` + + ` v1 receives security fixes only.\n`, + ); console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`); if (!authDisabled) { console.log( diff --git a/server/src/proxy-security.ts b/server/src/proxy-security.ts new file mode 100644 index 00000000..04f99bc3 --- /dev/null +++ b/server/src/proxy-security.ts @@ -0,0 +1,183 @@ +/** + * SSRF-prevention helpers for the /fetch reverse proxy. + * + * Extracted from index.ts so they can be unit-tested independently. + * + * ## DNS-rebinding / TOCTOU threat model + * + * `assertSafeProxyTarget` resolves the target hostname and validates every + * returned IP against the block-list. The resolved IPs are returned to the + * caller so they can be passed to `createPinnedAgent`, which builds an + * http/https.Agent whose `lookup` callback unconditionally returns the + * pre-validated IP. The agent is then passed to `node-fetch`, which calls the + * lookup hook instead of doing its own DNS query. + * + * Without this pinning there is a TOCTOU race: an attacker who controls the + * target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure + * instance-metadata) in the window between `assertSafeProxyTarget` returning + * and `fetch` performing its own resolution. Pinning eliminates that window. + */ + +import http from "node:http"; +import https from "node:https"; +import { isIP } from "node:net"; +import type dns from "node:dns"; +import { lookup as dnsLookup } from "node:dns/promises"; + +// --------------------------------------------------------------------------- +// Block-list +// --------------------------------------------------------------------------- + +/** + * Returns true if `ip` is a link-local or cloud-metadata address that must + * never be reachable through the proxy. + * + * Blocked ranges: + * - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS) + * - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above) + * - fe80::/10 (IPv6 link-local) + * - fd00:ec2::254 (AWS IPv6 instance-metadata) + */ +export function isBlockedProxyAddress(ip: string): boolean { + const kind = isIP(ip); + if (kind === 4) { + const parts = ip.split(".").map(Number); + return parts[0] === 169 && parts[1] === 254; + } + if (kind === 6) { + const addr = ip.toLowerCase(); + + // IPv4-mapped IPv6 — two serialization forms: + // dotted ::ffff:169.254.169.254 + // hex ::ffff:a9fe:a9fe + const mapped = addr.match(/^::ffff:(.+)$/); + if (mapped) { + const tail = mapped[1]; + if (isIP(tail) === 4) { + return isBlockedProxyAddress(tail); + } + const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (hex) { + const high = parseInt(hex[1], 16); + const low = parseInt(hex[2], 16); + return isBlockedProxyAddress( + `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`, + ); + } + } + + if (/^fe[89ab]/.test(addr)) return true; // fe80::/10 + return addr === "fd00:ec2::254"; // AWS IPv6 IMDS + } + return false; +} + +// --------------------------------------------------------------------------- +// DNS validation +// --------------------------------------------------------------------------- + +/** Raised when the /fetch proxy is asked to reach a disallowed target. */ +export class ProxyTargetError extends Error {} + +/** + * Resolves `parsedUrl`'s hostname and asserts that every returned IP is + * outside the block-list. + * + * Returns the list of validated IP strings so the caller can pin one of them + * into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window + * between this check and the actual `fetch()` call. + * + * @throws {ProxyTargetError} if the host cannot be resolved or any resolved + * address falls inside the block-list. + */ +export async function assertSafeProxyTarget(parsedUrl: URL): Promise { + const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets + + let addresses: string[]; + if (isIP(host)) { + addresses = [host]; + } else { + try { + addresses = (await dnsLookup(host, { all: true })).map((r) => r.address); + } catch { + throw new ProxyTargetError(`Could not resolve host: ${host}`); + } + } + + if (addresses.length === 0) { + throw new ProxyTargetError(`Could not resolve host: ${host}`); + } + + for (const address of addresses) { + if (isBlockedProxyAddress(address)) { + throw new ProxyTargetError( + `Refusing to proxy request to blocked address (${address})`, + ); + } + } + + return addresses; +} + +// --------------------------------------------------------------------------- +// IP-pinned agent factory +// --------------------------------------------------------------------------- + +/** + * Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always + * returns `pinnedIps`, bypassing the OS resolver entirely. + * + * Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP + * connection always goes to an IP that was validated by `assertSafeProxyTarget` + * — even if the domain's DNS record changes mid-flight. + * + * Every address `assertSafeProxyTarget` returned is handed to the hook, not + * just the first, so Node keeps its normal address-family fallback: a + * dual-stack host whose first address is unreachable (IPv6 on an IPv4-only + * network, say) still connects via a later one. The set it may choose from is + * exactly the prevalidated set, so the TOCTOU guarantee is unchanged. + * + * @param protocol - `"http:"` or `"https:"` (from `URL.protocol`) + * @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order + */ +export function createPinnedAgent( + protocol: string, + pinnedIps: string[], +): http.Agent | https.Agent { + if (pinnedIps.length === 0) { + throw new ProxyTargetError("No validated address to connect to"); + } + + const resolved: dns.LookupAddress[] = pinnedIps.map((address) => ({ + address, + family: isIP(address) === 6 ? 6 : 4, + })); + + // Node.js lookup signature: (hostname, options, callback). + // + // The callback shape depends on `options.all`. Node's `net.connect` runs with + // `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook + // with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing + // it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS. + // Both shapes are supported so the agent works on either call path. + const lookup = ( + _hostname: string, + opts: dns.LookupOptions | undefined, + callback: ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, + ) => void, + ): void => { + if (opts?.all) { + callback(null, resolved); + return; + } + callback(null, resolved[0].address, resolved[0].family); + }; + + if (protocol === "https:") { + return new https.Agent({ lookup } as https.AgentOptions); + } + return new http.Agent({ lookup } as http.AgentOptions); +} diff --git a/server/tsconfig.json b/server/tsconfig.json index b5a92612..8b3ad0ca 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -12,5 +12,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "packages", "**/*.spec.ts"] + "exclude": ["node_modules", "packages", "**/*.spec.ts", "src/**/__tests__/**"] } diff --git a/server/tsconfig.test.json b/server/tsconfig.test.json new file mode 100644 index 00000000..e25a6b70 --- /dev/null +++ b/server/tsconfig.test.json @@ -0,0 +1,12 @@ +// The build config excludes __tests__ so the suite never lands in the +// published `build/` output. This config exists only so the tests are still +// type-checked; it emits nothing and is run by `npm test`. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "packages"] +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts new file mode 100644 index 00000000..9a3bc821 --- /dev/null +++ b/server/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/__tests__/**/*.test.ts"], + }, +});