@modelcontextprotocol/inspector 2.3.0 → 2.4.0

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.
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet" />
10
10
  <title>MCP Inspector</title>
11
- <script type="module" crossorigin src="/assets/index-CwhDusR3.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CiUpWldH.css">
11
+ <script type="module" crossorigin src="/assets/index-JCsLrWVa.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DQVN73FG.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="color-scheme" content="light dark" />
6
- <!-- Isolation model: the inner iframe is sandboxed WITHOUT
6
+ <!-- Isolation model: by default the inner iframe is sandboxed WITHOUT
7
7
  `allow-same-origin`, so the untrusted app runs under an opaque
8
8
  ("null") origin. That is the structural boundary — the app cannot
9
9
  touch this proxy's DOM, window, or cookies, so it cannot bypass its
@@ -15,6 +15,18 @@
15
15
  resource-ready message's `sandbox` field; `allow-same-origin` is
16
16
  always stripped from a server-supplied value.
17
17
 
18
+ Dedicated-origin exception (#2056): an app whose UI resource declares
19
+ `_meta.ui.domain` is instead served from the host's own app-origin
20
+ listener, handed to us as the resource-ready message's `src`. That
21
+ frame IS granted `allow-same-origin` — an opaque origin sends
22
+ `Origin: null`, which defeats the CORS / OAuth-callback / API-key
23
+ allowlists the field exists to support. The boundary is preserved by
24
+ the origin itself rather than by opacity: the listener is on its own
25
+ port, so the app is cross-origin to this proxy and to the host, and
26
+ the grant is refused outright if the URL's origin equals this page's.
27
+ The grant is host-driven — it is never reachable from the server's
28
+ own `sandbox` string, which is still stripped.
29
+
18
30
  Network/resource egress from the inner document is constrained by a
19
31
  Content-Security-Policy that the HOST builds and bakes into the
20
32
  delivered HTML before it reaches this proxy (see
@@ -85,13 +97,21 @@
85
97
  // This is the origin we expect all parent messages to come from.
86
98
  const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
87
99
 
88
- // The inner iframe is sandboxed without allow-same-origin, so its
89
- // document has an opaque origin and every postMessage from it arrives
100
+ // The inner iframe is sandboxed without allow-same-origin by default, so
101
+ // its document has an opaque origin and every postMessage from it arrives
90
102
  // with event.origin === "null" (the literal string). That value is
91
103
  // checked below as a defense-in-depth assertion; the actual identity
92
104
  // proof is `event.source === inner.contentWindow`, which a sibling
93
105
  // frame cannot forge.
94
- const INNER_ORIGIN = "null";
106
+ //
107
+ // The dedicated-origin path (#2056) is the one exception: when the host
108
+ // supplies a `src` on its own app-origin listener, the frame is granted
109
+ // allow-same-origin so the app has a REAL origin — which is the entire
110
+ // point, since an opaque origin sends `Origin: null` and defeats any
111
+ // CORS / OAuth-callback / API-key allowlist the app's backend applies.
112
+ // `innerOrigin` is then that URL's origin, and the assertion above still
113
+ // holds against it.
114
+ let innerOrigin = "null";
95
115
 
96
116
  // Security self-test: verify iframe isolation is working correctly.
97
117
  // This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
@@ -158,7 +178,47 @@
158
178
  }
159
179
 
160
180
  if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
161
- const { html, sandbox, permissions } = event.data.params;
181
+ const { html, sandbox, permissions, src } = event.data.params;
182
+ // The host asked for the dedicated-origin path: it published the
183
+ // wrapped document to its own app-origin listener and is handing
184
+ // us the URL. Only an absolute http(s) URL is honored — anything
185
+ // else (a relative path, a `javascript:` or `data:` URL) falls
186
+ // through to the srcdoc path below rather than being navigated to.
187
+ let srcUrl = null;
188
+ if (typeof src === "string") {
189
+ try {
190
+ const parsed = new URL(src);
191
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
192
+ srcUrl = parsed;
193
+ } else {
194
+ console.warn("[Sandbox] ignoring non-http(s) src:", src);
195
+ }
196
+ } catch (e) {
197
+ console.warn("[Sandbox] ignoring unparseable src:", src);
198
+ }
199
+ }
200
+ // The app origin MUST differ from BOTH trusted origins — this
201
+ // proxy's own and the Inspector page above it. Granting
202
+ // allow-same-origin below is what makes the app's origin real, and
203
+ // an app sharing the proxy's origin would land in this realm
204
+ // outright; one sharing the HOST's origin is no better, since
205
+ // same-origin with the top document means direct access to
206
+ // `window.top`, its DOM and its storage — the cross-origin frame in
207
+ // between buys nothing once the inner document is same-origin with
208
+ // the thing it is trying to reach. Either is a host
209
+ // misconfiguration, not app input, so refuse rather than degrade
210
+ // quietly.
211
+ if (
212
+ srcUrl &&
213
+ (srcUrl.origin === window.location.origin ||
214
+ srcUrl.origin === EXPECTED_HOST_ORIGIN)
215
+ ) {
216
+ console.error(
217
+ "[Sandbox] refusing a src on a trusted origin:",
218
+ srcUrl.origin,
219
+ );
220
+ srcUrl = null;
221
+ }
162
222
  if (typeof sandbox === "string") {
163
223
  // Never grant allow-same-origin even if the server asks for it —
164
224
  // that would let the untrusted app reach this proxy's DOM and
@@ -179,13 +239,42 @@
179
239
  }
180
240
  inner.setAttribute("sandbox", safeTokens.join(" "));
181
241
  }
242
+ if (srcUrl) {
243
+ // Re-grant what the strip above removes unconditionally. Safe
244
+ // here and only here: the document is served from a DIFFERENT
245
+ // origin than this proxy (asserted above), so same-origin policy
246
+ // still denies it this realm's DOM — it gains its own origin,
247
+ // not ours.
248
+ const tokens = (inner.getAttribute("sandbox") || "")
249
+ .split(/\s+/)
250
+ .filter(Boolean);
251
+ if (!tokens.some((t) => t.toLowerCase() === "allow-same-origin")) {
252
+ tokens.push("allow-same-origin");
253
+ }
254
+ inner.setAttribute("sandbox", tokens.join(" "));
255
+ }
182
256
  // Set Permission Policy allow attribute if permissions are requested
183
257
  const allowAttribute = buildAllowAttribute(permissions);
184
258
  if (allowAttribute) {
185
259
  console.log("[Sandbox] Setting allow attribute:", allowAttribute);
186
260
  inner.setAttribute("allow", allowAttribute);
187
261
  }
188
- if (typeof html === "string") {
262
+ if (srcUrl) {
263
+ // Navigate to the host's app-origin URL. The per-app CSP is
264
+ // delivered as a real response header there (stronger than the
265
+ // <meta> the srcdoc path relies on), so no wrapping applies here
266
+ // — this proxy still never parses the untrusted bytes.
267
+ //
268
+ // Drop any `srcdoc` a previous render left behind first: it takes
269
+ // precedence over `src`, so a frame that had rendered an opaque
270
+ // document would keep showing it and never load this URL. Not
271
+ // reachable today (each render remounts the iframe, which reloads
272
+ // this document), but the cost of being wrong about that later is
273
+ // an app silently rendering the wrong bytes.
274
+ inner.removeAttribute("srcdoc");
275
+ innerOrigin = srcUrl.origin;
276
+ inner.src = srcUrl.href;
277
+ } else if (typeof html === "string") {
189
278
  // The host has already wrapped the app's HTML in a fixed shell
190
279
  // whose first <head> child is the CSP <meta>; assign it verbatim.
191
280
  // srcdoc is the only delivery path that works without
@@ -194,11 +283,34 @@
194
283
  // boundary). Known trade-off: a small set of libraries (e.g.
195
284
  // CesiumJS Map) don't initialise correctly under srcdoc — that
196
285
  // is accepted in exchange for the isolation guarantee.
286
+ //
287
+ // Restore the opaque-origin posture first. A frame that
288
+ // previously took the dedicated-origin path still carries
289
+ // `allow-same-origin`, and `innerOrigin` still names that origin;
290
+ // assigning `srcdoc` under those would put the untrusted document
291
+ // in THIS proxy's realm — the exact escape the double-iframe
292
+ // model exists to prevent. The sandbox attribute is read at
293
+ // navigation time and assigning `srcdoc` is the navigation, so
294
+ // stripping it here takes effect for the document being loaded.
295
+ const fallbackTokens = (inner.getAttribute("sandbox") || "")
296
+ .split(/\s+/)
297
+ .filter((t) => t && t.toLowerCase() !== "allow-same-origin");
298
+ inner.setAttribute("sandbox", fallbackTokens.join(" "));
299
+ innerOrigin = "null";
197
300
  inner.srcdoc = html;
198
301
  }
199
302
  } else {
200
303
  if (inner && inner.contentWindow) {
201
- inner.contentWindow.postMessage(event.data, "*");
304
+ // Target the inner frame's real origin on the dedicated-origin
305
+ // path, so a frame that navigated itself elsewhere stops
306
+ // receiving host messages. The opaque default has no targetable
307
+ // origin ("null" is not a valid targetOrigin), so it keeps "*" —
308
+ // safe there because the frame is `srcdoc` and cannot navigate
309
+ // to another origin's document without allow-top-navigation.
310
+ inner.contentWindow.postMessage(
311
+ event.data,
312
+ innerOrigin === "null" ? "*" : innerOrigin,
313
+ );
202
314
  }
203
315
  }
204
316
  } else if (event.source === inner.contentWindow) {
@@ -207,12 +319,12 @@
207
319
  // sandbox means its origin serialises as the literal string "null";
208
320
  // assert that as defense-in-depth so a future change that
209
321
  // accidentally re-grants allow-same-origin is caught here.
210
- if (event.origin !== INNER_ORIGIN) {
322
+ if (event.origin !== innerOrigin) {
211
323
  console.error(
212
324
  "[Sandbox] Rejecting message from inner iframe with unexpected origin:",
213
325
  event.origin,
214
- "expected opaque:",
215
- INNER_ORIGIN,
326
+ "expected:",
327
+ innerOrigin,
216
328
  );
217
329
  return;
218
330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelcontextprotocol/inspector",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "The Model Context Protocol Inspector",
5
5
  "keywords": [
6
6
  "MCP",
@@ -38,17 +38,18 @@
38
38
  "build:tui": "cd clients/tui && npm run build",
39
39
  "build:web": "cd clients/web && npm run build",
40
40
  "build:launcher": "cd clients/launcher && npm run build",
41
- "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run smoke && npm run ci:storybook",
41
+ "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run ci:storybook",
42
42
  "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook",
43
43
  "verify:build-gate": "node scripts/verify-build-gate.mjs",
44
+ "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs",
44
45
  "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs",
45
46
  "test:scripts": "node --test \"scripts/**/*.test.mjs\"",
46
47
  "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher",
47
48
  "verify:format-coverage": "node scripts/verify-format-coverage.mjs",
48
49
  "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs",
49
50
  "validate:core": "npm run format:check:core && npm run format:check:scripts && npm run format:check:shared && npm run lint:core && npm run lint:shared",
50
- "lint:core": "eslint \"core/**/*.{ts,tsx}\"",
51
- "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js",
51
+ "lint:core": "eslint \"core/**/*.{ts,tsx}\" --max-warnings 0",
52
+ "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js --max-warnings 0",
52
53
  "format:core": "prettier --write \"core/**/*.{ts,tsx}\"",
53
54
  "format:check:core": "prettier --check \"core/**/*.{ts,tsx}\"",
54
55
  "format:scripts": "prettier --write \"scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"",
@@ -65,14 +66,15 @@
65
66
  "coverage:tui": "cd clients/tui && npm run test:coverage",
66
67
  "coverage:web": "cd clients/web && npm run test:coverage",
67
68
  "coverage:launcher": "cd clients/launcher && npm run test:coverage",
68
- "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app",
69
+ "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser && npm run smoke:web:app && npm run smoke:web:elicit",
69
70
  "smoke:cli": "node scripts/smoke-cli.mjs",
70
71
  "smoke:tui": "node scripts/smoke-tui.mjs",
71
72
  "smoke:web": "node scripts/smoke-web.mjs",
72
73
  "smoke:web:browser": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-browser.mjs",
73
74
  "smoke:web:app": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-app.mjs",
75
+ "smoke:web:elicit": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-elicitation.mjs",
74
76
  "smoke:launcher": "node scripts/smoke-launcher.mjs",
75
- "pack:verify": "node scripts/pack-and-verify.mjs",
77
+ "pack:verify": "cd clients/web && npx playwright install chromium && cd ../.. && node scripts/pack-and-verify.mjs",
76
78
  "prepack": "npm run build",
77
79
  "postinstall": "node scripts/install-clients.mjs"
78
80
  },
@@ -93,6 +95,7 @@
93
95
  "ink": "^6.0.0",
94
96
  "open": "^10.2.0",
95
97
  "pino": "^9.14.0",
98
+ "proper-lockfile": "^4.1.2",
96
99
  "react": "^19.0.0",
97
100
  "undici": "^8.5.0",
98
101
  "vite": "^8.1.5",
@@ -104,6 +107,7 @@
104
107
  },
105
108
  "devDependencies": {
106
109
  "@eslint/js": "^10.0.1",
110
+ "@types/proper-lockfile": "^4.1.4",
107
111
  "eslint": "^10.8.0",
108
112
  "express": "^5.2.1",
109
113
  "globals": "^17.7.0",
@@ -1,15 +0,0 @@
1
- var __getOwnPropNames = Object.getOwnPropertyNames;
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
- var __commonJS = (cb, mod) => function __require2() {
9
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
- };
11
-
12
- export {
13
- __require,
14
- __commonJS
15
- };