@modelcontextprotocol/inspector 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/mcp.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>MCP Inspector</title>
8
- <script type="module" crossorigin src="/assets/index-DjSLdS7f.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BlUSNvoM.css">
8
+ <script type="module" crossorigin src="/assets/index-DdyO6kfY.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DXy0twoH.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root" class="w-full"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelcontextprotocol/inspector",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Model Context Protocol inspector",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Model Context Protocol a Series of LF Projects, LLC.",
@@ -42,19 +42,20 @@
42
42
  "start-client": "cd client && npm run preview",
43
43
  "test": "npm run prettier-check && cd client && npm test",
44
44
  "test-cli": "cd cli && npm run test",
45
+ "test-server": "cd server && npm run test",
45
46
  "test:e2e": "MCP_AUTO_OPEN_ENABLED=false npm run test:e2e --workspace=client",
46
47
  "prettier-fix": "prettier --write .",
47
48
  "prettier-check": "prettier --check .",
48
49
  "lint": "prettier --check . && cd client && npm run lint",
49
50
  "prepare": "husky && npm run build",
50
- "publish-all": "npm publish --workspaces --access public && npm publish --access public",
51
+ "publish-all": "npm publish --workspaces --access public --tag v1-latest && npm publish --access public --tag v1-latest",
51
52
  "update-version": "node scripts/update-version.js",
52
53
  "check-version": "node scripts/check-version-consistency.js"
53
54
  },
54
55
  "dependencies": {
55
- "@modelcontextprotocol/inspector-cli": "^1.0.0",
56
- "@modelcontextprotocol/inspector-client": "^1.0.0",
57
- "@modelcontextprotocol/inspector-server": "^1.0.0",
56
+ "@modelcontextprotocol/inspector-cli": "^1.0.2",
57
+ "@modelcontextprotocol/inspector-client": "^1.0.2",
58
+ "@modelcontextprotocol/inspector-server": "^1.0.2",
58
59
  "@modelcontextprotocol/sdk": "^1.25.2",
59
60
  "concurrently": "^9.2.0",
60
61
  "node-fetch": "^3.3.2",
@@ -80,13 +81,20 @@
80
81
  "overrides": {
81
82
  "get-intrinsic": "1.3.0",
82
83
  "shell-quote": "^1.8.4",
83
- "hono": "^4.12.25",
84
+ "hono": "^4.13.3",
84
85
  "form-data": "^4.0.6",
85
86
  "@babel/core@<=7.29.0": "^7.29.6",
86
87
  "esbuild@<0.28.1": "^0.28.1",
87
88
  "minimatch@<3.1.4": "3.1.5",
88
- "js-yaml@<4": "3.15.0",
89
- "js-yaml@>=4": "4.2.0"
89
+ "js-yaml@<4": "3.15.1",
90
+ "js-yaml@>=4": "4.3.1",
91
+ "@hono/node-server@<1.19.15": "^1.19.15",
92
+ "ip-address@<=10.3.0": "^10.5.0",
93
+ "fast-uri@<3.1.5": "^3.1.5",
94
+ "brace-expansion@<2": "^1.1.18",
95
+ "brace-expansion@>=4": "^5.0.9",
96
+ "nanoid@<3.3.18": "^3.3.18",
97
+ "postcss@<=8.5.22": "^8.5.26"
90
98
  },
91
99
  "engines": {
92
100
  "node": ">=22.7.5"
@@ -95,5 +103,9 @@
95
103
  "**/*.{js,ts,jsx,tsx,json,md}": [
96
104
  "prettier --write"
97
105
  ]
106
+ },
107
+ "allowScripts": {
108
+ "esbuild": false,
109
+ "fsevents": false
98
110
  }
99
111
  }
@@ -3,6 +3,7 @@ import cors from "cors";
3
3
  import { parseArgs } from "node:util";
4
4
  import { parse as shellParseArgs } from "shell-quote";
5
5
  import nodeFetch, { Headers as NodeHeaders } from "node-fetch";
6
+ import { assertSafeProxyTarget, createPinnedAgent, ProxyTargetError, } from "./proxy-security.js";
6
7
  // Type-compatible wrappers for node-fetch to work with browser-style types
7
8
  const fetch = nodeFetch;
8
9
  const Headers = NodeHeaders;
@@ -16,8 +17,6 @@ import rateLimit from "express-rate-limit";
16
17
  import { findActualExecutable } from "spawn-rx";
17
18
  import mcpProxy from "./mcpProxy.js";
18
19
  import { randomUUID, randomBytes, timingSafeEqual } from "node:crypto";
19
- import { lookup as dnsLookup } from "node:dns/promises";
20
- import { isIP } from "node:net";
21
20
  import { fileURLToPath } from "url";
22
21
  import { dirname, join } from "path";
23
22
  import { readFileSync } from "fs";
@@ -675,83 +674,16 @@ app.get("/health", (req, res) => {
675
674
  status: "ok",
676
675
  });
677
676
  });
678
- // Raised when the /fetch proxy is asked to reach a disallowed target, so the
679
- // route can distinguish an SSRF rejection (403) from an upstream failure (500).
680
- class ProxyTargetError extends Error {
681
- }
682
677
  const MAX_PROXY_REDIRECTS = 5;
683
- // The /fetch proxy is used by the client to reach OAuth discovery/token
684
- // endpoints on the connected MCP server (bypassing browser CORS). Connecting
685
- // to loopback and RFC1918/private LAN hosts is a core use case (testing local
686
- // or self-hosted MCP servers), so those stay reachable. We only block
687
- // link-local addresses — 169.254.0.0/16 (the AWS/GCP/Azure/etc. cloud metadata
688
- // endpoint 169.254.169.254 lives here) and IPv6 link-local / the AWS IPv6 IMDS
689
- // address — which are never legitimate proxy targets and are the real SSRF
690
- // exfiltration vector.
691
- function isBlockedProxyAddress(ip) {
692
- const kind = isIP(ip);
693
- if (kind === 4) {
694
- const parts = ip.split(".").map(Number);
695
- // 169.254.0.0/16 — IPv4 link-local (incl. cloud metadata 169.254.169.254)
696
- return parts[0] === 169 && parts[1] === 254;
697
- }
698
- if (kind === 6) {
699
- const addr = ip.toLowerCase();
700
- // IPv4-mapped IPv6 (::ffff:x). The WHATWG URL parser serializes the embedded
701
- // IPv4 in hex (::ffff:a9fe:a9fe for 169.254.169.254), while DNS and other
702
- // sources may use the dotted form (::ffff:169.254.169.254) — handle both by
703
- // extracting the IPv4 and re-checking it.
704
- const mapped = addr.match(/^::ffff:(.+)$/);
705
- if (mapped) {
706
- const tail = mapped[1];
707
- if (isIP(tail) === 4) {
708
- return isBlockedProxyAddress(tail);
709
- }
710
- const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
711
- if (hex) {
712
- const high = parseInt(hex[1], 16);
713
- const low = parseInt(hex[2], 16);
714
- return isBlockedProxyAddress(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
715
- }
716
- }
717
- // fe80::/10 — IPv6 link-local
718
- if (/^fe[89ab]/.test(addr)) {
719
- return true;
720
- }
721
- // fd00:ec2::254 — AWS IPv6 instance metadata endpoint
722
- return addr === "fd00:ec2::254";
723
- }
724
- return false;
725
- }
726
- // Resolves the target host and rejects it if any resolved address is a blocked
727
- // (link-local/metadata) address, mitigating SSRF including the DNS case where a
728
- // hostname resolves to the cloud metadata IP.
729
- async function assertSafeProxyTarget(parsedUrl) {
730
- const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
731
- let addresses;
732
- if (isIP(host)) {
733
- addresses = [host];
734
- }
735
- else {
736
- try {
737
- addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
738
- }
739
- catch {
740
- throw new ProxyTargetError(`Could not resolve host: ${host}`);
741
- }
742
- }
743
- if (addresses.length === 0) {
744
- throw new ProxyTargetError(`Could not resolve host: ${host}`);
745
- }
746
- for (const address of addresses) {
747
- if (isBlockedProxyAddress(address)) {
748
- throw new ProxyTargetError(`Refusing to proxy request to blocked address (${address})`);
749
- }
750
- }
751
- }
752
678
  // Fetch that follows redirects manually so every hop's target is re-validated
753
679
  // against assertSafeProxyTarget — otherwise an allowed host could redirect the
754
680
  // proxy into a blocked internal/metadata address.
681
+ //
682
+ // On each hop we call assertSafeProxyTarget (which resolves DNS and validates
683
+ // every returned IP) and then pass a createPinnedAgent to node-fetch so it
684
+ // connects to the IP we just validated instead of re-resolving the hostname.
685
+ // This eliminates the TOCTOU window between the block-list check and the
686
+ // actual TCP connection that a DNS-rebinding attack would exploit.
755
687
  async function safeProxyFetch(initialUrl, init) {
756
688
  let currentUrl = new URL(initialUrl);
757
689
  let method = init?.method ?? "GET";
@@ -761,12 +693,18 @@ async function safeProxyFetch(initialUrl, init) {
761
693
  if (!["http:", "https:"].includes(currentUrl.protocol)) {
762
694
  throw new ProxyTargetError("Only http/https URLs are allowed");
763
695
  }
764
- await assertSafeProxyTarget(currentUrl);
696
+ // Resolve DNS once, validate every address, and pin the connection to that
697
+ // validated set so node-fetch never does a second DNS lookup. All of them
698
+ // are passed through, so Node's address-family fallback still works while
699
+ // the reachable set stays limited to what was just validated.
700
+ const validatedAddresses = await assertSafeProxyTarget(currentUrl);
701
+ const agent = createPinnedAgent(currentUrl.protocol, validatedAddresses);
765
702
  const response = await fetch(currentUrl.toString(), {
766
703
  method,
767
704
  headers,
768
705
  body,
769
706
  redirect: "manual",
707
+ agent,
770
708
  });
771
709
  if (response.status >= 300 && response.status < 400) {
772
710
  const location = response.headers.get("location");
@@ -866,6 +804,9 @@ const PORT = parseInt(process.env.SERVER_PORT || DEFAULT_MCP_PROXY_LISTEN_PORT,
866
804
  const HOST = process.env.HOST || "localhost";
867
805
  const server = app.listen(PORT, HOST);
868
806
  server.on("listening", () => {
807
+ console.log(`\n⚠️ @modelcontextprotocol/inspector v1 is deprecated.\n` +
808
+ ` Upgrade: npx @modelcontextprotocol/inspector@latest\n` +
809
+ ` v1 receives security fixes only.\n`);
869
810
  console.log(`⚙️ Proxy server listening on ${HOST}:${PORT}`);
870
811
  if (!authDisabled) {
871
812
  console.log(`🔑 Session token: ${sessionToken}\n ` +
@@ -0,0 +1,154 @@
1
+ /**
2
+ * SSRF-prevention helpers for the /fetch reverse proxy.
3
+ *
4
+ * Extracted from index.ts so they can be unit-tested independently.
5
+ *
6
+ * ## DNS-rebinding / TOCTOU threat model
7
+ *
8
+ * `assertSafeProxyTarget` resolves the target hostname and validates every
9
+ * returned IP against the block-list. The resolved IPs are returned to the
10
+ * caller so they can be passed to `createPinnedAgent`, which builds an
11
+ * http/https.Agent whose `lookup` callback unconditionally returns the
12
+ * pre-validated IP. The agent is then passed to `node-fetch`, which calls the
13
+ * lookup hook instead of doing its own DNS query.
14
+ *
15
+ * Without this pinning there is a TOCTOU race: an attacker who controls the
16
+ * target domain can flip its DNS record to 169.254.169.254 (AWS/GCP/Azure
17
+ * instance-metadata) in the window between `assertSafeProxyTarget` returning
18
+ * and `fetch` performing its own resolution. Pinning eliminates that window.
19
+ */
20
+ import http from "node:http";
21
+ import https from "node:https";
22
+ import { isIP } from "node:net";
23
+ import { lookup as dnsLookup } from "node:dns/promises";
24
+ // ---------------------------------------------------------------------------
25
+ // Block-list
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Returns true if `ip` is a link-local or cloud-metadata address that must
29
+ * never be reachable through the proxy.
30
+ *
31
+ * Blocked ranges:
32
+ * - 169.254.0.0/16 (IPv4 link-local; includes 169.254.169.254 IMDS)
33
+ * - ::ffff:169.254.x.x (IPv4-mapped IPv6 variants of the above)
34
+ * - fe80::/10 (IPv6 link-local)
35
+ * - fd00:ec2::254 (AWS IPv6 instance-metadata)
36
+ */
37
+ export function isBlockedProxyAddress(ip) {
38
+ const kind = isIP(ip);
39
+ if (kind === 4) {
40
+ const parts = ip.split(".").map(Number);
41
+ return parts[0] === 169 && parts[1] === 254;
42
+ }
43
+ if (kind === 6) {
44
+ const addr = ip.toLowerCase();
45
+ // IPv4-mapped IPv6 — two serialization forms:
46
+ // dotted ::ffff:169.254.169.254
47
+ // hex ::ffff:a9fe:a9fe
48
+ const mapped = addr.match(/^::ffff:(.+)$/);
49
+ if (mapped) {
50
+ const tail = mapped[1];
51
+ if (isIP(tail) === 4) {
52
+ return isBlockedProxyAddress(tail);
53
+ }
54
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
55
+ if (hex) {
56
+ const high = parseInt(hex[1], 16);
57
+ const low = parseInt(hex[2], 16);
58
+ return isBlockedProxyAddress(`${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`);
59
+ }
60
+ }
61
+ if (/^fe[89ab]/.test(addr))
62
+ return true; // fe80::/10
63
+ return addr === "fd00:ec2::254"; // AWS IPv6 IMDS
64
+ }
65
+ return false;
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // DNS validation
69
+ // ---------------------------------------------------------------------------
70
+ /** Raised when the /fetch proxy is asked to reach a disallowed target. */
71
+ export class ProxyTargetError extends Error {
72
+ }
73
+ /**
74
+ * Resolves `parsedUrl`'s hostname and asserts that every returned IP is
75
+ * outside the block-list.
76
+ *
77
+ * Returns the list of validated IP strings so the caller can pin one of them
78
+ * into an HTTP agent (see `createPinnedAgent`), eliminating the TOCTOU window
79
+ * between this check and the actual `fetch()` call.
80
+ *
81
+ * @throws {ProxyTargetError} if the host cannot be resolved or any resolved
82
+ * address falls inside the block-list.
83
+ */
84
+ export async function assertSafeProxyTarget(parsedUrl) {
85
+ const host = parsedUrl.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
86
+ let addresses;
87
+ if (isIP(host)) {
88
+ addresses = [host];
89
+ }
90
+ else {
91
+ try {
92
+ addresses = (await dnsLookup(host, { all: true })).map((r) => r.address);
93
+ }
94
+ catch {
95
+ throw new ProxyTargetError(`Could not resolve host: ${host}`);
96
+ }
97
+ }
98
+ if (addresses.length === 0) {
99
+ throw new ProxyTargetError(`Could not resolve host: ${host}`);
100
+ }
101
+ for (const address of addresses) {
102
+ if (isBlockedProxyAddress(address)) {
103
+ throw new ProxyTargetError(`Refusing to proxy request to blocked address (${address})`);
104
+ }
105
+ }
106
+ return addresses;
107
+ }
108
+ // ---------------------------------------------------------------------------
109
+ // IP-pinned agent factory
110
+ // ---------------------------------------------------------------------------
111
+ /**
112
+ * Creates an `http.Agent` or `https.Agent` whose internal `lookup` hook always
113
+ * returns `pinnedIps`, bypassing the OS resolver entirely.
114
+ *
115
+ * Pass this agent to `node-fetch` on every hop of `safeProxyFetch` so the TCP
116
+ * connection always goes to an IP that was validated by `assertSafeProxyTarget`
117
+ * — even if the domain's DNS record changes mid-flight.
118
+ *
119
+ * Every address `assertSafeProxyTarget` returned is handed to the hook, not
120
+ * just the first, so Node keeps its normal address-family fallback: a
121
+ * dual-stack host whose first address is unreachable (IPv6 on an IPv4-only
122
+ * network, say) still connects via a later one. The set it may choose from is
123
+ * exactly the prevalidated set, so the TOCTOU guarantee is unchanged.
124
+ *
125
+ * @param protocol - `"http:"` or `"https:"` (from `URL.protocol`)
126
+ * @param pinnedIps - Validated IPv4/IPv6 address strings, in preference order
127
+ */
128
+ export function createPinnedAgent(protocol, pinnedIps) {
129
+ if (pinnedIps.length === 0) {
130
+ throw new ProxyTargetError("No validated address to connect to");
131
+ }
132
+ const resolved = pinnedIps.map((address) => ({
133
+ address,
134
+ family: isIP(address) === 6 ? 6 : 4,
135
+ }));
136
+ // Node.js lookup signature: (hostname, options, callback).
137
+ //
138
+ // The callback shape depends on `options.all`. Node's `net.connect` runs with
139
+ // `autoSelectFamily` enabled by default (Node >= 20), so it calls the hook
140
+ // with `{ all: true }` and expects an ARRAY of `{ address, family }` — handing
141
+ // it the scalar form there fails the connection with ERR_INVALID_IP_ADDRESS.
142
+ // Both shapes are supported so the agent works on either call path.
143
+ const lookup = (_hostname, opts, callback) => {
144
+ if (opts?.all) {
145
+ callback(null, resolved);
146
+ return;
147
+ }
148
+ callback(null, resolved[0].address, resolved[0].family);
149
+ };
150
+ if (protocol === "https:") {
151
+ return new https.Agent({ lookup });
152
+ }
153
+ return new http.Agent({ lookup });
154
+ }