Compare commits

...
Author SHA1 Message Date
Cliff Hall
85abfd4a28
chore: bump version to 1.0.2 (#2081)
Some checks failed
CLI Tests / test (push) Has been cancelled
Releases the pending v1 security and hygiene fixes: the js-yaml
override correction (#2072), the remaining audit advisories (#2074),
and the esbuild/fsevents install-script denials (#2079). Together they
take `npm audit` from 8 advisories to 0.

Version fields only — no functional change.

The lockfile version fields were edited directly rather than via
`npm run update-version`, which finishes with a bare `npm install`
that re-resolves the whole tree; on this branch that churns 164
packages including production deps (@modelcontextprotocol/sdk, hono,
@hono/node-server 1.x -> 2.x major, jose, ws).

Fixes #2080


Claude-Session: https://claude.ai/code/session_01Gq5jMmxRUphrVbfNbYVmQH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 20:22:13 -07:00
Cliff Hall
0681500e8f
chore: deny esbuild and fsevents install scripts (#2077)
npm 11.19 gates dependency lifecycle install scripts behind an
allowScripts allowlist and warns about any package not yet reviewed.
Three were pending: esbuild's postinstall and two copies of fsevents.

None of them is needed. esbuild resolves its platform binary through
optionalDependencies (@esbuild/darwin-*), so install.js is only a
validation/fallback-download step, and both fsevents copies ship a
prebuilt fsevents.node — their install script is just a node-gyp
rebuild fallback. Verified: builds and tests pass with the scripts
skipped.

Denying rather than approving keeps the warning quiet without granting
a standing "run this package's arbitrary install script" permission.


Claude-Session: https://claude.ai/code/session_01Gq5jMmxRUphrVbfNbYVmQH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 20:13:42 -07:00
Cliff Hall
d3085247ce
fix(deps): clear remaining npm audit advisories (#2075)
Adds overrides for the five advisories present in the production
dependency tree, plus two dev-only stragglers, bringing `npm audit`
to zero.

Only ip-address is meaningfully reachable: server/src/index.ts builds
sandboxRateLimiter from express-rate-limit, which parses IPs via
Address6 for subnet keying. Impact here is a rate-limit key bypass on
/sandbox rather than the SSRF the advisory titles imply, since req.ip
derives from the socket and the proxy binds localhost.

The other four are installed but unexercised: hono middleware is never
wired up, @hono/node-server's serve-static is never imported (only
getRequestListener), fast-uri is reached only for ajv format checks
whose result is never trusted for a host decision, and brace-expansion
expands serve-handler's config-supplied pattern rather than the
attacker-supplied path. nanoid and postcss are dev-only.

Every patched release exists within the current major line.
@hono/node-server is deliberately held at 1.19.17 rather than 2.x — a
major bump has no place on a deprecated branch.

Lock entries are patched in place rather than re-resolved, keeping the
diff to the affected packages.

Fixes #2074


Claude-Session: https://claude.ai/code/session_01Gq5jMmxRUphrVbfNbYVmQH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 19:51:42 -07:00
Cliff Hall
8f0c98cace
fix(deps): bump js-yaml overrides to patched 3.15.1 / 4.3.1 (#2073)
The root `overrides` block pinned js-yaml to versions inside the
advisory range (3.15.0 and 4.2.0), so `npm audit fix` reported a fix
as available and then changed nothing — overrides take precedence.

Bumps both pins to the patched `v3-legacy` / `v4-legacy` releases,
addressing GHSA-52cp-r559-cp3m and GHSA-5p4m-2wfm-xmqj.

Exposure is development-only: `npm ls js-yaml --omit=dev` is empty and
both copies are reachable only via eslint and ts-jest/istanbul, neither
of which ships in the published package.

Both patched versions have an identical dependency and bin shape, so
the lock entries were patched in place — 6 lines, no transitive churn.

Fixes #2072


Claude-Session: https://claude.ai/code/session_01Gq5jMmxRUphrVbfNbYVmQH

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:14:24 -07:00
Cliff Hall
cc416f0f93
Merge pull request #1732 from manjunathbhaskar/fix/dns-rebinding-toctou-safeproxyfetch
fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs
2026-08-20 16:17:17 -04:00
Cliff Hall
af79614c81
Merge branch 'v1/main' into fix/dns-rebinding-toctou-safeproxyfetch 2026-08-20 10:43:53 -04:00
Cliff Hall
c4898540be
Merge pull request #2066 from modelcontextprotocol/v1/chore/2065-workflow-branch-triggers
ci(v1): pin workflow branch filters to v1/main, re-enabling the e2e gate
2026-08-20 10:41:17 -04:00
cliffhall
95fa9a1c58 ci(v1): re-enable the Playwright suite on v1/main
`e2e_tests.yml` filtered on `branches: [main]` for both `push` and
`pull_request` — the same leftover as main.yml, but with a sharper
consequence: `main` never matches on this branch, so the filter matched
nothing and the suite has never run on `v1/main` at all. The branch's full
run history contains zero `Playwright Tests` runs, only
`Run install, format, lint, build, and test on every push` and `CLI Tests`.

Repointed both filters at `v1/main` so the gate actually gates. Unlike the
main.yml change this is a real behavior change — it turns a dead workflow
back on — so it is deliberately a separate commit.

Refs #2065

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-20 10:22:10 -04:00
cliffhall
c7dc8f707a ci(v1): pin workflow branch filters to v1/main
The push filter still listed `main`, and `pull_request` carried no branch
filter at all — both leftovers from when the v1 line was itself `main`.
Neither describes the topology now: `v1/main` publishes straight to the
`v1-latest` dist-tag and never merges into `main`, which carries v2 and
publishes `latest`.

No behavior change on any path that runs today. For `push`, GitHub runs the
workflow file from the pushed commit, so this file was only ever consulted
for `v1/main`; `main` could not match unless a commit on that branch carried
a v1-shaped tree. The same resolution rule made the unfiltered
`pull_request` harmless, since a PR's workflow comes from its merge ref.
The `release` trigger is untouched, so publishing is unaffected — it is
gated on the event type and the release's target commit, never on these
filters.

The value is that the file now states its intent, and the stale `main`
entry can no longer become live if anything is ever restored onto that
branch.

Closes #2065

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-20 09:58:05 -04:00
Cliff Hall
fb4a753cd0
Merge branch 'v1/main' into fix/dns-rebinding-toctou-safeproxyfetch 2026-08-20 09:45:40 -04:00
cliffhall
923c58edec fix(server): pin all validated addresses; keep tests out of the build
Addresses the round-2 Copilot review (both suppressed comments).

- createPinnedAgent now takes the whole validated address list instead of
  a single IP, so Node's address-family fallback still works: a
  dual-stack host whose first address is unreachable (IPv6 on an
  IPv4-only network) connects via a later one. The set net.connect may
  choose from is exactly the prevalidated set, so the TOCTOU guarantee is
  unchanged. Empty input throws ProxyTargetError rather than pinning
  undefined.
- Tests: dual-stack pass-through, the empty-list guard, and an
  end-to-end request where the first pinned address is a black hole
  (2001:db8::1) and the connection must complete via the second.
- server/tsconfig.json excludes src/**/__tests__/** so the suite is no
  longer emitted into build/ and published with the package (verified:
  build/__tests__ is gone). tsconfig.test.json keeps the suite
  type-checked, and `npm test` runs it before vitest.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-19 17:05:42 -04:00
cliffhall
0e1bc1d402 fix(server): make the pinned lookup honor Node's all:true callback shape
Addresses the round-1 Copilot review.

- createPinnedAgent: Node's net.connect runs with autoSelectFamily on
  (Node >= 20), so it calls the lookup hook with `{ all: true }` and
  requires an ARRAY of `{ address, family }`. The scalar-only callback
  failed every hostname request with ERR_INVALID_IP_ADDRESS, so /fetch
  could only reach literal-IP targets. Both callback shapes are now
  supported. Reproduced against Node 22/26 before and after.
- Tests: cover the `{ all: true }` shape for IPv4 and IPv6, and add an
  end-to-end request through the agent over the production path
  (node-fetch -> http.Agent -> net.connect) using an unresolvable
  hostname, so the request can only succeed via the pinned IP. Verified
  these three fail without the fix. Replaced the `Function`-typed casts
  with a typed pinnedLookup() helper.
- CI: run the new server suite (.github/workflows/main.yml) and add a
  root `test-server` script, so this regression coverage is enforced.
- package-lock.json: sync the server workspace manifest mirror with the
  added @types/node and vitest devDependencies.
- index.ts: drop the `agent as any` cast and its eslint-disable — the
  node-fetch RequestInit already types `agent`, and tsc is clean without
  the suppression.
- Formatting: prettier-check now passes (it failed on the two new files).

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-19 16:59:06 -04:00
Cliff Hall
47f92841bc
fix: v1 dist-tag must be v1-latest — npm rejects v1 (#1829)
#1828 pinned v1 publishes to `--tag v1`, following the runbook (#1804 §1). That
tag name is not legal: npm rejects any dist-tag that parses as a SemVer range,
and `v1` parses as `1.x`.

    npm error Tag name must not be a valid SemVer range: v1

Found while running the companion `npm dist-tag add` step, which failed the
same way. So the merged flag would not have "published to the wrong tag" — the
next v1 security release would have failed to publish at all, and only at
release time.

`v1-latest` is accepted (verified against semver.validRange, along with
`legacy`, which was the other candidate). The workflow comment now records both
failure modes so neither the flag nor the specific tag name gets "cleaned up"
later.


Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:52:57 -04:00
Cliff Hall
4f3e7fa167
chore: pin v1 publishes to the v1 dist-tag (#1828)
`publish-all` had no `--tag`, so npm assigned `latest` to whatever it
published. That was correct for 1.0.1 — the deprecation release had to take
`latest` to reach npx users — but it becomes a trap the moment 2.0.0 ships: a
1.0.2 security fix published from `v1/main` would move `latest` back to v1, and
every `npx @modelcontextprotocol/inspector` in the world would silently get the
deprecated line.

`--tag v1` on both halves covers all four package names — `--workspaces` fans
out to inspector-client/-server/-cli, and the second invocation is the root.

Nothing enforces this flag, and the cost of dropping it surfaces months later
in a way that looks like an npm bug rather than a missing argument, so the
workflow carries a comment at the point of use.

Phase 3 of the v2 go-live runbook (#1804 §1).


Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:47:46 -04:00
Cliff Hall
9d74acf526
chore: v1 deprecation notices in all four packages, bump to 1.0.1 (#1827)
Some checks failed
CLI Tests / test (push) Has been cancelled
* chore: v1 deprecation notices in all four packages, bump to 1.0.1

Phase 2 of the v2 go-live runbook (#1804 §4). v1 publishes four separately
installable names, so each gets its own notice — a root-README-only change is
invisible to anyone who installed a sub-package. `cli/` and `server/` ship no
README at all, so for those two the runtime banner plus the forthcoming
`npm deprecate` (#1816) are the only notice their users ever get.

Notices, all using identical wording so the runtime banner and the npm warning
agree:

- root `README.md` and `client/README.md`
- `cli/src/cli.ts` — stderr banner in `main()`. Also covers the root
  `mcp-inspector` bin, which is `cli/build/cli.js`. Deliberately **stderr**:
  `--cli` mode emits JSON on stdout and callers pipe it, so a banner there
  would corrupt output. Verified against a real server — stdout stays
  parseable JSON while the banner goes to stderr.
- `server/src/index.ts` — alongside the existing proxy listening log
- `client/bin/start.js` — alongside the existing startup URL log
- `client/src/components/DeprecationBanner.tsx` — dismissible in-app banner,
  dismissal persisted in localStorage with both read and write guarded so a
  storage failure degrades to "banner reappears" rather than breaking the app

AGENTS.md is replaced with a hard stop: v1 takes security fixes only, by issue
only, and agents must never open a PR against v1/main.

Version bumped to 1.0.1 across all four package.json files plus the lockfile
(`npm run update-version`); `check-version` passes. The lockfile diff is
restricted to the version fields — the `libc` churn a newer npm wanted to
introduce was reverted as unrelated.

`publish-all` is deliberately NOT changed here: 1.0.1 must take `latest`, which
is the entire point of shipping this notice to npx users. Adding `--tag v1` is
#1816, immediately after this publishes.

Note for review: `client/src/App.tsx` shows ~836 changed lines but `git diff -w`
shows 8 — wrapping the layout in a flex column added one nesting level and
prettier re-indented the subtree. Review it with `-w`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD

* docs: replace the README screenshot with one showing the deprecation banner

Captured from this branch's build: v1.0.1 in the sidebar heading, the
dismissible deprecation banner across the top, connected to the everything
server with tools listed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD

* docs: pin the README screenshot to v1/main instead of main

The image URL must stay absolute — a relative path breaks on the npm package
page, which renders the README outside any repo context. But it was pinned to
`main`, so the v1 README rendered whatever `main` happened to hold: today the
pre-deprecation screenshot, and after the v2 tree swap (#1817) either the v2
screenshot or a 404 if that path doesn't exist there.

Pinning to `v1/main` keeps it absolute for npm, shows the deprecation banner
once this merges, and survives the swap. Verified all candidate refs resolve
200 — GitHub disambiguates the slash in `v1/main` as the ref without needing a
`refs/heads/` prefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:29:02 -04:00
cliffhall
3188ee3a20 add v1/main to branches in main.yml 2026-07-27 20:32:01 -04:00
manjunathbhaskar
9140789869 fix: prevent DNS-rebinding TOCTOU in safeProxyFetch by pinning resolved IPs
Previously assertSafeProxyTarget resolved the target hostname and validated
the IPs, but safeProxyFetch then passed the raw URL to node-fetch, which
performed its own DNS lookup. In the window between the two resolutions an
attacker who controls the domain's TTL could flip the record to
169.254.169.254 (cloud-metadata), causing fetch() to connect to the instance-
metadata service even though the block-list check passed.

Fix: assertSafeProxyTarget now returns the validated IP addresses. On each
hop of safeProxyFetch we call createPinnedAgent() to build an http/https.Agent
whose lookup hook unconditionally returns the pre-validated IP. node-fetch
uses that hook instead of the OS resolver, closing the TOCTOU window entirely.

Refactoring: isBlockedProxyAddress, assertSafeProxyTarget, and the new
createPinnedAgent are extracted to server/src/proxy-security.ts so they can
be unit-tested in isolation. A vitest suite is added to the server package
(the first unit tests for this package) with 26 cases covering the block-list,
DNS validation, IP-pinning, and the TOCTOU guarantee itself.
2026-07-21 12:46:25 +02:00
21 changed files with 1230 additions and 595 deletions

View file

@ -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:

View file

@ -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"

View file

@ -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.

View file

@ -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

View file

@ -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.",

View file

@ -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<void> {
handleError(error);
});
printDeprecationNotice();
try {
const args = parseArgs();

View file

@ -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.

View file

@ -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...");

View file

@ -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.",

View file

@ -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 (
<div className="flex h-screen bg-background">
<div
style={{
width: sidebarWidth,
minWidth: 200,
maxWidth: 600,
transition: isSidebarDragging ? "none" : "width 0.15s",
}}
className="bg-card border-r border-border flex flex-col h-full relative"
>
<Sidebar
connectionStatus={connectionStatus}
transportType={transportType}
setTransportType={setTransportType}
command={command}
setCommand={setCommand}
args={args}
setArgs={setArgs}
sseUrl={sseUrl}
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
setOauthClientSecret={setOauthClientSecret}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
serverImplementation={serverImplementation}
/>
<div className="flex flex-col h-screen bg-background">
<DeprecationBanner />
<div className="flex flex-1 min-h-0">
<div
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"
/>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs
value={activeTab}
className="w-full p-4"
onValueChange={(value) => {
setActiveTab(value);
window.location.hash = value;
}}
>
<TabsList className="mb-4 py-0">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
<TabsTrigger
value="tasks"
disabled={!serverCapabilities?.tasks}
>
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="apps">
<AppWindow className="w-4 h-4 mr-2" />
Apps
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
</TabsTrigger>
<TabsTrigger value="sampling" className="relative">
<Hash className="w-4 h-4 mr-2" />
Sampling
{pendingSampleRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingSampleRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="elicitations" className="relative">
<MessageSquare className="w-4 h-4 mr-2" />
Elicitations
{pendingElicitationRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingElicitationRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="roots">
<FolderTree className="w-4 h-4 mr-2" />
Roots
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
<TabsTrigger value="metadata">
<Settings className="w-4 h-4 mr-2" />
Metadata
</TabsTrigger>
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
) : (
<>
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
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}
/>
<PromptsTab
prompts={prompts}
listPrompts={() => {
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}
/>
<ToolsTab
serverSupportsTaskRequests={
!!serverCapabilities?.tasks?.requests?.tools?.call
}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
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);
}}
/>
<TasksTab
tasks={tasks}
listTasks={() => {
clearError("tasks");
listTasks();
}}
clearTasks={() => {
setTasks([]);
setNextTaskCursor(undefined);
}}
cancelTask={cancelTask}
selectedTask={selectedTask}
setSelectedTask={(task) => {
clearError("tasks");
setSelectedTask(task);
}}
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<AppsTab
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
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]);
}}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
<SamplingTab
pendingRequests={pendingSampleRequests}
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<ElicitationTab
pendingRequests={pendingElicitationRequests}
onResolve={handleResolveElicitation}
/>
<RootsTab
roots={roots}
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
<MetadataTab
metadata={metadata}
onMetadataChange={handleMetadataChange}
/>
</>
)}
</div>
</Tabs>
) : isAuthDebuggerVisible ? (
<Tabs
defaultValue={"auth"}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<AuthDebuggerWrapper />
</Tabs>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
Connect to an MCP server to start inspecting
</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Need to configure authentication?
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAuthDebuggerVisible(true)}
>
Open Auth Settings
</Button>
</div>
</div>
)}
</div>
<div
className="relative border-t border-border"
style={{
height: `${historyPaneHeight}px`,
width: sidebarWidth,
minWidth: 200,
maxWidth: 600,
transition: isSidebarDragging ? "none" : "width 0.15s",
}}
className="bg-card border-r border-border flex flex-col h-full relative"
>
<Sidebar
connectionStatus={connectionStatus}
transportType={transportType}
setTransportType={setTransportType}
command={command}
setCommand={setCommand}
args={args}
setArgs={setArgs}
sseUrl={sseUrl}
setSseUrl={setSseUrl}
env={env}
setEnv={setEnv}
config={config}
setConfig={setConfig}
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
oauthClientId={oauthClientId}
setOauthClientId={setOauthClientId}
oauthClientSecret={oauthClientSecret}
setOauthClientSecret={setOauthClientSecret}
oauthScope={oauthScope}
setOauthScope={setOauthScope}
onConnect={connectMcpServer}
onDisconnect={disconnectMcpServer}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
connectionType={connectionType}
setConnectionType={setConnectionType}
serverImplementation={serverImplementation}
/>
<div
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
onMouseDown={handleDragStart}
>
<div className="w-8 h-1 rounded-full bg-border" />
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"
/>
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto">
{mcpClient ? (
<Tabs
value={activeTab}
className="w-full p-4"
onValueChange={(value) => {
setActiveTab(value);
window.location.hash = value;
}}
>
<TabsList className="mb-4 py-0">
<TabsTrigger
value="resources"
disabled={!serverCapabilities?.resources}
>
<Files className="w-4 h-4 mr-2" />
Resources
</TabsTrigger>
<TabsTrigger
value="prompts"
disabled={!serverCapabilities?.prompts}
>
<MessageSquare className="w-4 h-4 mr-2" />
Prompts
</TabsTrigger>
<TabsTrigger
value="tools"
disabled={!serverCapabilities?.tools}
>
<Hammer className="w-4 h-4 mr-2" />
Tools
</TabsTrigger>
<TabsTrigger
value="tasks"
disabled={!serverCapabilities?.tasks}
>
<ListTodo className="w-4 h-4 mr-2" />
Tasks
</TabsTrigger>
<TabsTrigger value="apps">
<AppWindow className="w-4 h-4 mr-2" />
Apps
</TabsTrigger>
<TabsTrigger value="ping">
<Bell className="w-4 h-4 mr-2" />
Ping
</TabsTrigger>
<TabsTrigger value="sampling" className="relative">
<Hash className="w-4 h-4 mr-2" />
Sampling
{pendingSampleRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingSampleRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="elicitations" className="relative">
<MessageSquare className="w-4 h-4 mr-2" />
Elicitations
{pendingElicitationRequests.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">
{pendingElicitationRequests.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="roots">
<FolderTree className="w-4 h-4 mr-2" />
Roots
</TabsTrigger>
<TabsTrigger value="auth">
<Key className="w-4 h-4 mr-2" />
Auth
</TabsTrigger>
<TabsTrigger value="metadata">
<Settings className="w-4 h-4 mr-2" />
Metadata
</TabsTrigger>
</TabsList>
<div className="w-full">
{!serverCapabilities?.resources &&
!serverCapabilities?.prompts &&
!serverCapabilities?.tools ? (
<>
<div className="flex items-center justify-center p-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
The connected server does not support any MCP
capabilities
</p>
</div>
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
</>
) : (
<>
<ResourcesTab
resources={resources}
resourceTemplates={resourceTemplates}
listResources={() => {
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}
/>
<PromptsTab
prompts={prompts}
listPrompts={() => {
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}
/>
<ToolsTab
serverSupportsTaskRequests={
!!serverCapabilities?.tasks?.requests?.tools?.call
}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
clearTools={() => {
setTools([]);
setNextToolCursor(undefined);
cacheToolOutputSchemas([]);
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
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);
}}
/>
<TasksTab
tasks={tasks}
listTasks={() => {
clearError("tasks");
listTasks();
}}
clearTasks={() => {
setTasks([]);
setNextTaskCursor(undefined);
}}
cancelTask={cancelTask}
selectedTask={selectedTask}
setSelectedTask={(task) => {
clearError("tasks");
setSelectedTask(task);
}}
error={errors.tasks}
nextCursor={nextTaskCursor}
/>
<AppsTab
sandboxPath={`${getMCPProxyAddress(config)}/sandbox`}
tools={tools}
listTools={() => {
clearError("tools");
listTools();
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
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]);
}}
/>
<ConsoleTab />
<PingTab
onPingClick={() => {
void sendMCPRequest(
{
method: "ping" as const,
},
EmptyResultSchema,
);
}}
/>
<SamplingTab
pendingRequests={pendingSampleRequests}
onApprove={handleApproveSampling}
onReject={handleRejectSampling}
/>
<ElicitationTab
pendingRequests={pendingElicitationRequests}
onResolve={handleResolveElicitation}
/>
<RootsTab
roots={roots}
setRoots={setRoots}
onRootsChange={handleRootsChange}
/>
<AuthDebuggerWrapper />
<MetadataTab
metadata={metadata}
onMetadataChange={handleMetadataChange}
/>
</>
)}
</div>
</Tabs>
) : isAuthDebuggerVisible ? (
<Tabs
defaultValue={"auth"}
className="w-full p-4"
onValueChange={(value) => (window.location.hash = value)}
>
<AuthDebuggerWrapper />
</Tabs>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-lg text-gray-500 dark:text-gray-400">
Connect to an MCP server to start inspecting
</p>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
Need to configure authentication?
</p>
<Button
variant="outline"
size="sm"
onClick={() => setIsAuthDebuggerVisible(true)}
>
Open Auth Settings
</Button>
</div>
</div>
)}
</div>
<div className="h-full overflow-auto">
<HistoryAndNotifications
requestHistory={requestHistory}
serverNotifications={notifications}
onClearHistory={clearRequestHistory}
onClearNotifications={handleClearNotifications}
/>
<div
className="relative border-t border-border"
style={{
height: `${historyPaneHeight}px`,
}}
>
<div
className="absolute w-full h-4 -top-2 cursor-row-resize flex items-center justify-center hover:bg-accent/50 dark:hover:bg-input/40"
onMouseDown={handleDragStart}
>
<div className="w-8 h-1 rounded-full bg-border" />
</div>
<div className="h-full overflow-auto">
<HistoryAndNotifications
requestHistory={requestHistory}
serverNotifications={notifications}
onClearHistory={clearRequestHistory}
onClearNotifications={handleClearNotifications}
/>
</div>
</div>
</div>
</div>

View file

@ -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 (
<div
role="status"
className="flex items-center justify-between gap-4 px-4 py-2 bg-yellow-100 text-yellow-900 border-b border-yellow-300 dark:bg-yellow-900/40 dark:text-yellow-100 dark:border-yellow-700"
>
<p className="text-sm">
<span className="font-semibold">
⚠️ MCP Inspector v1 is deprecated.
</span>{" "}
Upgrade with{" "}
<code className="px-1 py-0.5 rounded bg-yellow-200 dark:bg-yellow-800">
npx @modelcontextprotocol/inspector@latest
</code>
. v1 receives security fixes only.
</p>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss deprecation notice"
className="shrink-0 p-1 rounded hover:bg-yellow-200 dark:hover:bg-yellow-800"
>
<X className="w-4 h-4" />
</button>
</div>
);
};
export default DeprecationBanner;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Before After
Before After

92
package-lock.json generated
View file

@ -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"
}
}
}

View file

@ -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
}
}

View file

@ -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",

View file

@ -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<typeof vi.fn>;
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<void>((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<void>((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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
});

View file

@ -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<void> {
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(

View file

@ -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<string[]> {
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);
}

View file

@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "packages", "**/*.spec.ts"]
"exclude": ["node_modules", "packages", "**/*.spec.ts", "src/**/__tests__/**"]
}

12
server/tsconfig.test.json Normal file
View file

@ -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"]
}

Some files were not shown because too many files have changed in this diff Show more