From d5e35b52ee70a99942aba21226f4c18d3d8e44ff Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 28 Jul 2026 01:59:00 -0400 Subject: [PATCH 001/941] chore: clear all runtime dependency vulnerabilities; 2.0.0-rc.2 (#1837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps every Dependabot alert that reaches users, then bumps to rc.2 so the swept tree gets the same live-registry validation rc.1 got. Deliberate: rc.1 validated a specific dependency tree, and changing it invalidates that proof. Result: **zero prod-scope high/critical vulnerabilities** across all five manifests (root + four clients). Achieved with lockfile changes ONLY — no package.json touched, no declared range widened, no major upgrade. Every one of these was a stale lockfile pin sitting inside an existing caret range: hono → 4.12.32 (needed 4.12.27) fast-uri → 3.1.4 (needed 3.1.3) postcss → 8.5.23 (needed 8.5.18) brace-expansion → 1.1.16 (needed 1.1.16) js-yaml → 4.3.0 (needed 4.3.0) ws → 8.21.0/1 (needed 8.21.0) flatted → 3.4.3 (needed 3.4.2) Two dev-scope clusters remain, both deliberately untouched — neither ships (the `files` allowlist is `clients/*/build`, `clients/web/dist`, and `scripts/install-clients.mjs`), and both need real work that does not belong immediately before a release: - eslint (5 alerts × 5 lockfiles): a `brace-expansion` DoS via `minimatch`, fixable only by eslint@10 — a major bump across every package, likely requiring rule migration. The glob patterns involved come from the repo's own config, never attacker-controlled. - vitest (3 critical, clients/web): a circular exact-pin knot — `@vitest/browser-playwright@4.1.0` requires `@vitest/browser@"4.1.0"` exactly, `vitest@4.1.0` requires `@vitest/browser-playwright@"4.1.0"` exactly, and `@storybook/addon-vitest` constrains the set further. `npm i` fails ERESOLVE; untangling means moving Storybook too. `npm run ci` passes on the swept tree — full chain including the coverage gate, build gate, smokes, and 462 Storybook tests. Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD Co-authored-by: Claude Opus 5 (1M context) --- clients/cli/package-lock.json | 72 +++-- clients/launcher/package-lock.json | 54 ++-- clients/tui/package-lock.json | 254 ++++++++--------- clients/web/package-lock.json | 444 ++++++++++++++++------------- package-lock.json | 157 +++++----- package.json | 2 +- 6 files changed, 515 insertions(+), 468 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index d9a12d31..be393849 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -2145,16 +2145,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "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": { @@ -2504,21 +2504,21 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2528,10 +2528,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3336,9 +3350,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -3878,9 +3892,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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4401,9 +4415,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4721,9 +4735,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -4741,7 +4755,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 4c112ddc..c908566b 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -440,9 +440,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -460,9 +457,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -480,9 +474,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -500,9 +491,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -520,9 +508,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -540,9 +525,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -854,14 +836,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "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": { @@ -1132,7 +1116,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.15", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1631,7 +1617,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -1825,9 +1813,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1849,9 +1834,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1873,9 +1855,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1897,9 +1876,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2023,7 +1999,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.14", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2149,7 +2127,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2167,7 +2147,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index e805f79c..68dcd356 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -2274,16 +2274,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "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": { @@ -2667,9 +2667,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3450,9 +3450,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -4039,9 +4039,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.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4548,9 +4548,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4836,9 +4836,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -4856,7 +4856,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5605,9 +5605,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -5622,9 +5622,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -5639,9 +5639,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -5656,9 +5656,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -5673,9 +5673,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -5690,9 +5690,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -5707,9 +5707,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -5724,9 +5724,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -5741,9 +5741,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -5758,9 +5758,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -5775,9 +5775,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -5792,9 +5792,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -5809,9 +5809,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -5826,9 +5826,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -5843,9 +5843,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -5860,9 +5860,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -5877,9 +5877,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -5894,9 +5894,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -5911,9 +5911,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -5928,9 +5928,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -5945,9 +5945,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -5962,9 +5962,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -5979,9 +5979,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -5996,9 +5996,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -6013,9 +6013,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -6030,9 +6030,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -6047,9 +6047,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6060,32 +6060,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/type-check": { diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 0107b0ad..f7ffffcd 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -87,12 +87,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -101,9 +101,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -111,21 +111,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -142,13 +142,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -158,14 +158,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -175,37 +175,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -215,27 +215,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -243,26 +243,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -281,31 +281,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -313,13 +313,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1096,9 +1096,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,7 +1108,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1120,9 +1120,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1157,9 +1157,9 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1500,13 +1500,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1540,19 +1540,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -3383,16 +3370,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "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": { @@ -3997,9 +3984,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "version": "2.11.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.5.tgz", + "integrity": "sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4010,20 +3997,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -4033,10 +4020,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4045,9 +4045,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -4065,11 +4065,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4167,9 +4167,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001779", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -4473,9 +4473,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -4694,9 +4694,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.397", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.397.tgz", + "integrity": "sha512-khGTy9U9x02KEtsKM8vx5A62BsRmcOsIgDpWr1ImE32Ax8GxHGPHZf+Eu9H8zOOyHJnB0jTbseyTHbq2XCT8yw==", "dev": true, "license": "ISC" }, @@ -4767,9 +4767,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4849,9 +4849,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -4860,8 +4860,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -5185,12 +5185,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5229,9 +5230,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -5369,9 +5370,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -5523,16 +5524,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -5748,9 +5749,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5868,9 +5869,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" @@ -6110,10 +6111,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7649,9 +7660,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -7684,11 +7695,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -7985,9 +7999,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -8123,9 +8137,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -8143,7 +8157,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8328,12 +8342,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -9013,14 +9028,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -9719,17 +9734,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -10343,9 +10375,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/package-lock.json b/package-lock.json index 058f0d87..b1b861a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.1", + "version": "2.0.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.1", + "version": "2.0.0-rc.2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -412,13 +412,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -452,19 +452,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -1498,21 +1485,21 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "peer": true, "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1522,6 +1509,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.16", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", @@ -1952,9 +1953,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "peer": true, "dependencies": { @@ -2290,12 +2291,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2328,9 +2330,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "funding": [ { "type": "github", @@ -2606,9 +2608,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "peer": true, "dependencies": { @@ -2619,9 +2621,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2838,9 +2840,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" @@ -3426,9 +3428,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -3724,9 +3726,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -3743,7 +3745,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3818,13 +3820,14 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "peer": true, "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -4116,15 +4119,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "peer": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4452,18 +4455,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "peer": true, "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { diff --git a/package.json b/package.json index 065c4e10..c5728afd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.1", + "version": "2.0.0-rc.2", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 73fe059072ab0c7fdfe32d44aacc85728e771773 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 28 Jul 2026 02:43:48 -0400 Subject: [PATCH 002/941] fix(deps): raise vite to 8.1.5 to close three dev-server file-read advisories; 2.0.0-rc.3 (#1841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1837 sweep moved vite to 8.0.16 in the root and tui manifests but left `clients/web` pinned at **8.0.0** — `npm update` reported "up to date" and only an explicit install would move it. That left five open advisories, three high: GHSA-fx2h-pf6j-xcff high `server.fs.deny` bypass via Windows ADS GHSA-v2wj-q39q-566r high `server.fs.deny` bypassed with queries GHSA-p9ff-h696-f583 high arbitrary file read via the dev server GHSA-v6wh-96g9-6wx3 medium launch-editor NTLMv2 hash disclosure GHSA-4w7w-66w2-5vf9 medium path traversal in optimized deps These are worth fixing rather than dismissing as "dev-only". Unlike the other remaining dev-scope alerts, `server.fs.deny` bypass is *remotely triggerable*: a malicious page can read arbitrary files from anyone running `npm run dev`. Published-package users are unaffected — they get the built `dist` and never run vite — but contributors are. Also raises the declared floor to `^8.1.5` in all three manifests that declare vite. The root previously declared `^8.0.0`, which still *permitted* the vulnerable 8.0.0 on a fresh resolve, so pinning the lockfile alone would have left a regression path open. Bumped to rc.3 rather than folding this into 2.0.0: vite builds the shipped bundle, so changing it changes the artifact, and the tree that ships should be the tree that was validated against the live registry. rc.2 validated a different tree. `npm run ci` passes — full chain including the coverage gate, build gate, smokes, and 462 Storybook tests. Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD Co-authored-by: Claude Opus 5 (1M context) --- clients/tui/package-lock.json | 196 ++++++++++++++--------------- clients/tui/package.json | 2 +- clients/web/package-lock.json | 227 ++++++++++++++++------------------ clients/web/package.json | 2 +- package-lock.json | 200 +++++++++++++++--------------- package.json | 4 +- 6 files changed, 312 insertions(+), 319 deletions(-) diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 68dcd356..c5c596d6 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -39,7 +39,7 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16", + "vite": "^8.1.5", "vite-node": "^6.0.0", "vitest": "^4.1.0" } @@ -335,21 +335,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -358,9 +358,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1357,14 +1357,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1376,9 +1376,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1392,9 +1392,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1409,9 +1409,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1426,9 +1426,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1443,9 +1443,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1460,9 +1460,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1477,9 +1477,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1494,9 +1494,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1511,9 +1511,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1528,9 +1528,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -1545,9 +1545,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -1562,9 +1562,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -1579,9 +1579,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -1596,9 +1596,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -1606,18 +1606,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -1632,9 +1632,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -2013,9 +2013,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4755,9 +4755,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5048,13 +5048,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5064,21 +5064,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rollup": { @@ -6210,16 +6210,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -6236,7 +6236,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/clients/tui/package.json b/clients/tui/package.json index 7799b1f7..77a018f5 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -58,7 +58,7 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16", + "vite": "^8.1.5", "vite-node": "^6.0.0", "vitest": "^4.1.0" } diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index f7ffffcd..810270fe 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -74,7 +74,7 @@ "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0", + "vite": "^8.1.5", "vitest": "^4.1.0", "yaml": "^2.9.0" } @@ -417,21 +417,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -440,9 +440,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -1800,20 +1800,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@neoconfetti/react": { @@ -1823,20 +1825,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "dev": true, "license": "MIT", "funding": { @@ -1857,9 +1849,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -1874,9 +1866,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -1891,9 +1883,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -1908,9 +1900,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -1925,9 +1917,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -1942,9 +1934,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -1959,9 +1951,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -1976,9 +1968,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -1993,9 +1985,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -2010,9 +2002,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -2027,9 +2019,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -2044,9 +2036,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -2061,9 +2053,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], @@ -2071,16 +2063,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -2095,9 +2089,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -2842,9 +2836,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -8811,14 +8805,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -8827,27 +8821,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -9492,14 +9486,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10115,18 +10109,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -10142,8 +10135,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/clients/web/package.json b/clients/web/package.json index e2a0dee2..81a8d4af 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -96,7 +96,7 @@ "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0", + "vite": "^8.1.5", "vitest": "^4.1.0", "yaml": "^2.9.0" } diff --git a/package-lock.json b/package-lock.json index b1b861a1..6afae123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -30,7 +30,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "undici": "^8.5.0", - "vite": "^8.0.0", + "vite": "^8.1.5", "yaml": "^2.9.0", "zod": "^4.3.6" }, @@ -63,20 +63,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -84,9 +84,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -712,13 +712,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -730,9 +730,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -745,9 +745,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", "cpu": [ "arm64" ], @@ -761,9 +761,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", "cpu": [ "arm64" ], @@ -777,9 +777,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", "cpu": [ "x64" ], @@ -793,9 +793,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", "cpu": [ "x64" ], @@ -809,9 +809,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", "cpu": [ "arm" ], @@ -825,9 +825,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", "cpu": [ "arm64" ], @@ -841,9 +841,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", "cpu": [ "arm64" ], @@ -857,9 +857,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", "cpu": [ "ppc64" ], @@ -873,9 +873,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", "cpu": [ "s390x" ], @@ -889,9 +889,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", "cpu": [ "x64" ], @@ -905,9 +905,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", "cpu": [ "x64" ], @@ -921,9 +921,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", "cpu": [ "arm64" ], @@ -937,27 +937,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", "cpu": [ "arm64" ], @@ -971,9 +971,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", "cpu": [ "x64" ], @@ -999,9 +999,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -3668,9 +3668,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -3949,12 +3949,12 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3964,21 +3964,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, "node_modules/router": { @@ -4563,15 +4563,15 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "bin": { @@ -4588,7 +4588,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index c5728afd..5cdd5b31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", @@ -94,7 +94,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "undici": "^8.5.0", - "vite": "^8.0.0", + "vite": "^8.1.5", "yaml": "^2.9.0", "zod": "^4.3.6" }, From 7aebf168e6277ea26b1f04a7987a1cd11328ec83 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 28 Jul 2026 03:17:31 -0400 Subject: [PATCH 003/941] chore: 2.0.0 (#1842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 go-live release (#1818, phase 5 of #1804). This is byte-identical to 2.0.0-rc.3 apart from dropping the prerelease suffix, which is what makes rc.3's validation meaningful: the tree that ships is the tree that was validated against the live registry. `2.0.0` has no hyphen, so the derivation added in #1834 resolves it to `latest` — this is the release that takes the tag from 1.0.1. Validated across three release candidates: - rc.1 caught two publish-path defects invisible to every local check — `NODE_AUTH_TOKEN` pointing at a non-existent secret (shadowing OIDC trusted publishing) and a missing npm CLI upgrade (trusted publishing needs >= 11.5.1; Node 22 bundles 10.x). Both fixed in #1836. - rc.2 validated the dependency sweep (#1837) that cleared every prod-scope advisory using lockfile-only changes. - rc.3 validated the vite 8.1.5 bump (#1841) closing three high-severity dev-server file-read advisories. Each RC was verified with a cold `node:22` container install driving the published tarball end to end — install, `--help`, and a real `--cli tools/list` against a live stdio server. `latest` remained on 1.0.1 throughout, proving the `--tag` derivation. Zero open Dependabot alerts at time of release. Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD Co-authored-by: Claude Opus 5 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6afae123..b7f5a1ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.3", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.3", + "version": "2.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 5cdd5b31..2adc0d5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0-rc.3", + "version": "2.0.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From fb1b0cb41c7b19e08334025ce118d48af1394967 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Tue, 28 Jul 2026 04:12:02 -0400 Subject: [PATCH 004/941] docs: restore SECURITY.md, lost in the v2 tree swap (#1843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SECURITY.md` existed on the pre-swap `main` (ac3c1a12) and still exists on `v1/main`, but the v2 tree never had one — so #1817 silently removed the security policy from the default branch. That is the wrong file to be missing right now: it is where private vulnerability reporting is advertised, and it disappeared in the same window as 2.0.0 shipping and external pull requests being turned off (#1820). Losing the PR path and the security-report path together would leave an outside reporter with no documented route at all. Restored from `v1/main` with the additions #1820 asks for: - A supported-versions table covering all four package names — v2 supported, the v1 line security-fixes-only under the `v1-latest` dist-tag, anything below 1.0.0 unsupported. The three sub-packages are named explicitly since they exist only on v1 and are deprecated. - A note that the repository does not accept outside pull requests, but that this explicitly does NOT apply to security reports, which must go through the advisory process rather than any public channel. Without that, the new issues-only policy reads as "no way to reach us". - "Whether it affects v2, v1, or both" added to the report checklist, now that two lines are live. Verified private vulnerability reporting is enabled on the repo, so the advisory link is a working route rather than an aspiration. Claude-Session: https://claude.ai/code/session_01Txmv2qqv3yeKgRzoqXytzD Co-authored-by: Claude Opus 5 (1M context) --- SECURITY.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..3e26f216 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Supported Versions + +| Version | Package | Support | +| ------- | ---------------------------------------- | ------------------------------------------------------- | +| **2.x** | `@modelcontextprotocol/inspector` | ✅ Actively supported | +| 1.x | `@modelcontextprotocol/inspector` | ⚠️ **Security fixes only**, published under `v1-latest` | +| 1.x | `@modelcontextprotocol/inspector-client` | ⚠️ Security fixes only | +| 1.x | `@modelcontextprotocol/inspector-server` | ⚠️ Security fixes only | +| 1.x | `@modelcontextprotocol/inspector-cli` | ⚠️ Security fixes only | +| < 1.0.0 | any | ❌ Unsupported | + +v2 ships as a **single** package — `@modelcontextprotocol/inspector`. The three +`inspector-client` / `inspector-server` / `inspector-cli` sub-packages exist only +on the v1 line and are deprecated. + +v1 development happens on the `v1/main` branch and is limited to security fixes. +Those releases are published under the **`v1-latest`** dist-tag so they never +displace the current v2 release: + +```bash +npm i @modelcontextprotocol/inspector@v1-latest +``` + +Everything else — features, bug fixes, improvements — lands in v2 only. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. Private vulnerability reporting is enabled, so this is the +fastest route to a maintainer. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +Note that this repository does not accept pull requests from outside contributors +(see [CONTRIBUTORS.md](./CONTRIBUTORS.md)) — **this does not apply to security +reports**, which should always go through the advisory process above rather than +any public channel. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Whether it affects v2, v1, or both +- Any suggested fixes (optional) From 5d91d4acd04158fdd783d49f232fb7298f1ad896 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 29 Jul 2026 01:19:48 +0200 Subject: [PATCH 005/941] fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846) A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server) rejects a tools/call whose argument carries an `x-mcp-header` annotation when the matching `Mcp-Param-*` request header is missing (-32020 "header mismatch"). The Inspector never sent that header. The SDK only mirrors inside `client.callTool()` (and skips it entirely in a browser environment), but the Inspector routes tools/call through `client.request()` for manual MRTR driving (#1704), so the SDK never mirrors for us — on any transport. On top of that, the remote (web) transport dropped per-request headers before they reached the backend's upstream fetch. - core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders` + value encoding (Base64 sentinel for non-ASCII/whitespace/empty values) and add `mcpParamHeadersForTool`. - InspectorClient.attemptToolCall: on a modern connection, build the headers from the tool's declarations + call arguments and attach them to the tools/call request options. `Protocol.request` forwards `headers` to the transport and preserves them across MRTR retry legs, so the direct StreamableHTTP transport applies them. Fixes CLI/TUI. - RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest: forward per-send headers and apply them to the backend's upstream transport.send. The browser can't set cross-origin headers, but the Node backend can — where the Inspector's real request is issued. Fixes web. Verified end-to-end against the live GitHub MCP Server (modern HTTP). Closes #1846 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd * review: harden backend header allowlist, fix breadcrumbs, trim comments Address Copilot review on #1847: - server.ts: restrict client-supplied per-send headers to the `Mcp-Param-` prefix (string values only) via `mcpParamHeadersOnly` before applying them upstream, so the per-send channel can't inject arbitrary headers (e.g. Authorization). `settings.headers` remains the channel for configured upstream headers. Covered by new unit tests. - Fix stale `#1810` breadcrumbs → `#1846` in the two new tests. - Trim the verbose mirroring comments in inspectorClient/types/remote transport. - Drop the redundant base64-on-the-wire integration test (encoding is unit- tested; wire delivery is proven by the verbatim test), remove two unneeded test casts, and cover the boolean `true` branch. Not changed (declined with reason on the PR): buildMcpParamHeaders remains a faithful port of the SDK's helper and does not re-validate values against the declared JSON Schema type — arg types are enforced by schema validation on both sides, and matching the SDK's conforming wire output is the module's goal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd * review: mirror Mcp-Param-* on the task-augmented tools/call too Address the maintainer review on #1847: - `callToolStream` (the "Run as task" path) built its own request options and never mirrored, so a task-augmented `tools/call` on an annotated tool was still rejected by a strict modern server with -32020. It now mirrors like the plain path. - Extract the two blocks both `tools/call` entry points had duplicated — `convertStringToolArgs` (the string-arg coercion) and `applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't drift again. That duplication is why the task path was missed. - Regression test: `callToolStream` against the modern `get_weather` server asserts the POST carries `Mcp-Param-City` (verified failing without the fix). - README: the modern-network showcase claimed mirroring is skipped in the browser so the web client gets -32020, and that CLI/TUI mirror. Both are now wrong — the Inspector mirrors itself, on every client. AGENTS: note that xMcpHeader.ts now also builds the wire headers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t * docs: correct the stale "browser omits Mcp-Param-*" copy Both statements predate this PR and are now the opposite of true, per the wire capture on #1847 (proxy-observed upstream headers, web leg included). - ToolDetailPanel: the mirrored-headers note claimed the browser omits `Mcp-Param-*`. It now says the web client's headers are applied by the Node backend that issues the upstream request. - createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated tool is "uncallable from the web client against a strict modern server" — the exact wrong conclusion to reach while debugging a -32020. It now records that the Inspector builds the headers itself, and that they do NOT travel this `/api/fetch` proxy path: they ride the transport's per-send `headers` through `/api/mcp/send`. Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx matches the section title only). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: cliffhall Co-authored-by: Claude Opus 5 (1M context) Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd --- AGENTS.md | 4 +- README.md | 2 +- .../ToolDetailPanel/ToolDetailPanel.tsx | 4 +- clients/web/src/test/core/xMcpHeader.test.ts | 152 +++++++++++++++ ...spectorClient-xmcpheader-mirroring.test.ts | 184 ++++++++++++++++++ .../remote/remoteClientTransport-unit.test.ts | 52 +++++ .../mcp/remote/server-extra-coverage.test.ts | 38 ++++ .../integration/mcp/remote/transport.test.ts | 70 ++++++- core/json/xMcpHeader.ts | 119 +++++++++++ core/mcp/inspectorClient.ts | 79 +++++--- core/mcp/remote/createRemoteFetch.ts | 11 +- core/mcp/remote/node/server.ts | 31 ++- core/mcp/remote/remoteClientTransport.ts | 3 + core/mcp/remote/types.ts | 5 + 14 files changed, 720 insertions(+), 34 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5861e60a..5a5d48b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ inspector/ │ ├── json/ # JSON utilities and parameter/argument conversion │ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header` │ │ # annotation scan/validation + mirrored-param -│ │ # derivation, used by the Tools tab — #1632) +│ │ # derivation, used by the Tools tab — #1632; +│ │ # plus `Mcp-Param-*` header building for the +│ │ # wire, used by both `tools/call` paths — #1846) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks diff --git a/README.md b/README.md index c6100602..235be143 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vi A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) -`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. +`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: the SDK only mirrors `Mcp-Param-*` inside `client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes `tools/call` through `client.request()` to drive MRTR manually, so it builds the mirrored headers itself (#1846) — on **every** client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So `get_weather` is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms. `test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`). diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 64b0ef12..a74c63a9 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -306,8 +306,8 @@ export function ToolDetailPanel({ ))} These argument values are mirrored into HTTP headers on the - call. The SDK sends them only on a Node/proxy transport — the - browser omits Mcp-Param-* headers. + call. In the web client the Mcp-Param-* headers are + applied by the Node backend that issues the upstream request. )} diff --git a/clients/web/src/test/core/xMcpHeader.test.ts b/clients/web/src/test/core/xMcpHeader.test.ts index 0f043da0..07235c89 100644 --- a/clients/web/src/test/core/xMcpHeader.test.ts +++ b/clients/web/src/test/core/xMcpHeader.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { scanXMcpHeaderDeclarations, getMirroredHeaderParams, + buildMcpParamHeaders, + mcpParamHeadersForTool, MCP_PARAM_HEADER_PREFIX, X_MCP_HEADER_KEY, } from "@inspector/core/json/xMcpHeader.js"; @@ -279,3 +281,153 @@ describe("getMirroredHeaderParams", () => { ).toEqual([]); }); }); + +function declsFor(inputSchema: Tool["inputSchema"]) { + const scan = scanXMcpHeaderDeclarations(inputSchema); + if (!scan.valid) throw new Error(`expected valid scan: ${scan.reason}`); + return scan.declarations; +} + +const P = MCP_PARAM_HEADER_PREFIX; + +function decodeSentinel(value: string): string { + const inner = value.slice("=?base64?".length, -"?=".length); + const bin = atob(inner); + const bytes = Uint8Array.from(bin, (ch) => ch.codePointAt(0) ?? 0); + return new TextDecoder().decode(bytes); +} + +describe("buildMcpParamHeaders", () => { + const decls = declsFor({ + type: "object", + properties: { + owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" }, + count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, + flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" }, + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + }, + }); + + it("mirrors a string argument verbatim into Mcp-Param-{Name}", () => { + expect(buildMcpParamHeaders(decls, { owner: "octocat" })).toEqual({ + [`${P}owner`]: "octocat", + }); + }); + + it("stringifies boolean and numeric values per the spec", () => { + expect( + buildMcpParamHeaders(decls, { + count: 42, + flag: false, + ratio: 3.5, + }), + ).toEqual({ + [`${P}Count`]: "42", + [`${P}Flag`]: "false", + [`${P}Ratio`]: "3.5", + }); + expect(buildMcpParamHeaders(decls, { flag: true })).toEqual({ + [`${P}Flag`]: "true", + }); + }); + + it("omits declarations whose value is absent or null", () => { + expect(buildMcpParamHeaders(decls, { owner: null })).toEqual({}); + expect(buildMcpParamHeaders(decls, {})).toEqual({}); + }); + + it("omits non-primitive values rather than emitting malformed headers", () => { + expect( + buildMcpParamHeaders(decls, { + owner: { nested: true }, + count: [1, 2], + }), + ).toEqual({}); + }); + + it("omits non-finite numbers and unsafe integers", () => { + expect(buildMcpParamHeaders(decls, { ratio: Infinity })).toEqual({}); + expect(buildMcpParamHeaders(decls, { ratio: NaN })).toEqual({}); + expect( + buildMcpParamHeaders(decls, { count: Number.MAX_SAFE_INTEGER + 2 }), + ).toEqual({}); + }); + + it("base64-wraps values that are not safe plain-ASCII field values", () => { + const out = buildMcpParamHeaders(decls, { owner: "münchen" }); + const encoded = out[`${P}owner`]; + expect(encoded.startsWith("=?base64?")).toBe(true); + expect(encoded.endsWith("?=")).toBe(true); + expect(decodeSentinel(encoded)).toBe("münchen"); + }); + + it("base64-wraps empty, whitespace-padded, and sentinel-colliding values", () => { + const empty = buildMcpParamHeaders(decls, { owner: "" })[`${P}owner`]; + expect(decodeSentinel(empty)).toBe(""); + const padded = buildMcpParamHeaders(decls, { owner: " x " })[`${P}owner`]; + expect(decodeSentinel(padded)).toBe(" x "); + const collide = "=?base64?zzz?="; + const wrapped = buildMcpParamHeaders(decls, { owner: collide })[ + `${P}owner` + ]; + expect(wrapped).not.toBe(collide); + expect(decodeSentinel(wrapped)).toBe(collide); + }); + + it("reads a nested property path", () => { + const nested = declsFor({ + type: "object", + properties: { + filter: { + type: "object", + properties: { + city: { type: "string", [X_MCP_HEADER_KEY]: "City" }, + }, + }, + }, + }); + expect( + buildMcpParamHeaders(nested, { filter: { city: "London" } }), + ).toEqual({ [`${P}City`]: "London" }); + expect(buildMcpParamHeaders(nested, { filter: "not-an-object" })).toEqual( + {}, + ); + }); +}); + +describe("mcpParamHeadersForTool", () => { + it("builds headers for a tool's valid annotations", () => { + expect( + mcpParamHeadersForTool( + tool({ + type: "object", + properties: { + owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" }, + }, + }), + { owner: "octocat" }, + ), + ).toEqual({ [`${P}owner`]: "octocat" }); + }); + + it("returns {} for a tool with no annotations", () => { + expect( + mcpParamHeadersForTool( + tool({ type: "object", properties: { a: { type: "string" } } }), + { a: "x" }, + ), + ).toEqual({}); + }); + + it("returns {} for a tool whose annotations are invalid", () => { + expect( + mcpParamHeadersForTool( + tool({ + type: "object", + properties: { a: { type: "object", [X_MCP_HEADER_KEY]: "A" } }, + }), + { a: "x" }, + ), + ).toEqual({}); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts new file mode 100644 index 00000000..fadb2987 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, + createGetWeatherTool, +} from "@modelcontextprotocol/inspector-test-server"; +import type { Tool } from "@modelcontextprotocol/client"; + +/** + * SEP-2243 `x-mcp-header` → `Mcp-Param-*` mirroring on `tools/call` (#1846). + * + * The SDK only mirrors inside `client.callTool()` (and skips it in a browser + * environment); the Inspector routes `tools/call` through `client.request()` + * for manual MRTR driving (#1704), so it mirrors the headers itself. A strict + * modern server (e.g. GitHub's) rejects a call whose annotated argument isn't + * mirrored, so this must ride the wire. Here we spy on the transport `fetch` and + * assert the `tools/call` POST carries the mirrored header with the spec's + * value encoding. + */ +describe("x-mcp-header Mcp-Param-* mirroring on tools/call", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // Ignore server stop errors + } + server = null; + } + }); + + /** Records the request headers of every `tools/call` POST the client sends. */ + function makeSpyFetch(): { + fetch: typeof fetch; + toolCallHeaders: Headers[]; + } { + const toolCallHeaders: Headers[] = []; + const spy: typeof fetch = async (input, init) => { + const body = init?.body; + if (typeof body === "string" && body.includes('"tools/call"')) { + try { + const parsed = JSON.parse(body) as { method?: string }; + if (parsed.method === "tools/call") { + toolCallHeaders.push(new Headers(init?.headers)); + } + } catch { + // Non-JSON body — ignore. + } + } + return fetch(input, init); + }; + return { fetch: spy, toolCallHeaders }; + } + + async function connectModern( + url: string, + fetchFn: typeof fetch, + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode, fetch: fetchFn }, + versionNegotiation: eraToVersionNegotiation("auto"), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + async function startWeatherServer(): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("xmcpheader-test", "1.0.0"), + tools: [createEchoTool(), createGetWeatherTool()], + modern: {}, + }); + await started.start(); + server = started; + return started; + } + + async function weatherTool(c: InspectorClient): Promise { + const { tools } = await c.listTools(); + const weather = tools.find((t) => t.name === "get_weather"); + expect(weather).toBeDefined(); + return weather!; + } + + it("mirrors an annotated argument into the Mcp-Param-* request header", async () => { + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + expect(connected.getProtocolEra()).toBe("modern"); + + const result = await connected.callTool(await weatherTool(connected), { + city: "London", + }); + + expect(result.success).toBe(true); + expect(spy.toolCallHeaders.length).toBeGreaterThan(0); + const sent = spy.toolCallHeaders.at(-1)!; + expect(sent.get("Mcp-Param-City")).toBe("London"); + }); + + it("mirrors on the task-augmented tools/call too (callToolStream)", async () => { + // The "Run as task" path builds its own request options, so it needs the + // same mirroring — a strict modern server rejects the task-augmented + // `tools/call` with -32020 when the header is missing, exactly as it does + // the plain one. + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + + const result = await connected.callToolStream( + await weatherTool(connected), + { city: "London" }, + undefined, + undefined, + { ttl: 60_000 }, + ); + + expect(result.success).toBe(true); + expect(spy.toolCallHeaders.at(-1)!.get("Mcp-Param-City")).toBe("London"); + }); + + it("sends no Mcp-Param-* header for a tool without annotations", async () => { + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + + const { tools } = await connected.listTools(); + const echo = tools.find((t) => t.name === "echo")!; + await connected.callTool(echo, { message: "hi" }); + + const sent = spy.toolCallHeaders.at(-1)!; + let sawMcpParam = false; + sent.forEach((_v, k) => { + if (k.toLowerCase().startsWith("mcp-param-")) sawMcpParam = true; + }); + expect(sawMcpParam).toBe(false); + }); + + it("does not mirror on a legacy connection", async () => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("xmcpheader-legacy", "1.0.0"), + tools: [createGetWeatherTool()], + modern: { legacy: "stateless" }, + }); + await started.start(); + server = started; + + const spy = makeSpyFetch(); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode, fetch: spy.fetch }, + versionNegotiation: eraToVersionNegotiation("legacy"), + }, + ); + await connected.connect(); + client = connected; + expect(connected.getProtocolEra()).toBe("legacy"); + + await connected.callTool(await weatherTool(connected), { city: "London" }); + const sent = spy.toolCallHeaders.at(-1)!; + expect(sent.get("Mcp-Param-City")).toBeNull(); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 05b8346d..fddc10c9 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -642,6 +642,58 @@ describe("RemoteClientTransport (focused branch coverage)", () => { await t.close(); }); + it("forwards per-send headers (SEP-2243 Mcp-Param-*) in the send body", async () => { + let sentBody: { headers?: Record } | undefined; + const encoder = new TextEncoder(); + let sseController: ReadableStreamDefaultController | null = + null; + const pushSseMessage = (message: JSONRPCMessage) => { + const payload = JSON.stringify({ type: "message", data: message }); + sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); + if (url.includes("/events")) { + return new Response( + new ReadableStream({ + start(controller) { + sseController = controller; + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), + { status: 200 }, + ); + } + if (url.includes("/send")) { + sentBody = JSON.parse(init!.body as string); + const requestId = ( + sentBody as { message: { id?: string | number } } + ).message.id; + pushSseMessage({ jsonrpc: "2.0", id: requestId!, result: {} }); + return jsonResponse({ ok: true }); + } + return jsonResponse({ ok: true }); + }, + ); + const t = new RemoteClientTransport( + { + baseUrl: "http://remote.test", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + }, + CONFIG, + ); + await t.start(); + await t.send( + { jsonrpc: "2.0", id: 7, method: "tools/call" }, + { headers: { "Mcp-Param-City": "London" } }, + ); + expect(sentBody?.headers).toEqual({ "Mcp-Param-City": "London" }); + await t.close(); + }); + it("throws Remote send failed with status on non-OK send", async () => { const t = makeTransport({ events: () => diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 04e55ed2..613d4c6a 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -22,6 +22,7 @@ import type pinoType from "pino"; import { createRemoteApp, requestIdForSendWait, + mcpParamHeadersOnly, } from "@inspector/core/mcp/remote/node/server.js"; import { InMemorySecretStore, @@ -139,6 +140,43 @@ describe("server.ts supplemental coverage", () => { }); }); + describe("mcpParamHeadersOnly (SEP-2243 upstream header allowlist, #1846)", () => { + it("keeps only Mcp-Param-* headers (case-insensitive)", () => { + expect( + mcpParamHeadersOnly({ + "Mcp-Param-City": "London", + "mcp-param-owner": "octocat", + }), + ).toEqual({ "Mcp-Param-City": "London", "mcp-param-owner": "octocat" }); + }); + + it("drops non-Mcp-Param headers a client tries to inject", () => { + expect( + mcpParamHeadersOnly({ + Authorization: "Bearer evil", + "X-Custom": "nope", + "Mcp-Param-City": "London", + }), + ).toEqual({ "Mcp-Param-City": "London" }); + }); + + it("drops non-string values", () => { + expect( + mcpParamHeadersOnly({ + "Mcp-Param-Bad": 5, + "Mcp-Param-City": "London", + }), + ).toEqual({ "Mcp-Param-City": "London" }); + }); + + it("returns undefined when nothing survives the filter", () => { + expect(mcpParamHeadersOnly({ Authorization: "x" })).toBeUndefined(); + expect(mcpParamHeadersOnly(undefined)).toBeUndefined(); + expect(mcpParamHeadersOnly(null)).toBeUndefined(); + expect(mcpParamHeadersOnly("not-an-object")).toBeUndefined(); + }); + }); + describe("/api/log forwardLogEvent shapes", () => { let h: Harness; let records: Array<{ level: string; args: unknown[] }>; diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index 35f9ec70..d66ae36a 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -30,9 +30,13 @@ import { createTestServerHttp, getTestMcpServerCommand, createEchoTool, + createGetWeatherTool, createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; -import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { + eraToVersionNegotiation, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; interface StartRemoteServerOptions { logger?: pino.Logger; @@ -447,6 +451,70 @@ describe("Remote transport e2e", () => { await client.disconnect(); } }); + + it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => { + // A modern (2026-07-28) server whose `get_weather` tool annotates `city` + // with `x-mcp-header: "City"`. The SDK's modern handler validates the + // mirrored header and rejects the call with -32020 when it is missing, so + // a *successful* call proves the header rode browser → backend → upstream. + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createGetWeatherTool()], + serverType: "streamable-http", + modern: {}, + }); + await mcpHttpServer.start(); + + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + const createTransport = createRemoteTransport({ baseUrl, authToken }); + const client = new InspectorClient(config, { + environment: { transport: createTransport }, + versionNegotiation: eraToVersionNegotiation("auto"), + }); + const fetchRequestLogState = new FetchRequestLogState(client); + + try { + await client.connect(); + expect(client.getProtocolEra()).toBe("modern"); + + const { tools } = await client.listTools(); + const weather = tools.find((t) => t.name === "get_weather"); + expect(weather).toBeDefined(); + + const invocation = await client.callTool(weather!, { city: "London" }); + // Success only happens if the strict modern server saw Mcp-Param-City. + expect(invocation.success).toBe(true); + + // And the backend's upstream fetch tracking shows the mirrored header on + // the wire it sent to the modern server. + const toolCallPost = fetchRequestLogState + .getFetchRequests() + .filter((r) => r.method === "POST") + .find((r) => { + const lowered: Record = {}; + for (const [k, v] of Object.entries(r.requestHeaders ?? {})) { + lowered[k.toLowerCase()] = v; + } + return lowered["mcp-param-city"] !== undefined; + }); + expect(toolCallPost).toBeDefined(); + const lowered: Record = {}; + for (const [k, v] of Object.entries( + toolCallPost?.requestHeaders ?? {}, + )) { + lowered[k.toLowerCase()] = v; + } + expect(lowered["mcp-param-city"]).toBe("London"); + } finally { + await client.disconnect(); + } + }); }); describe("authentication", () => { diff --git a/core/json/xMcpHeader.ts b/core/json/xMcpHeader.ts index 97000dd8..739eb6a2 100644 --- a/core/json/xMcpHeader.ts +++ b/core/json/xMcpHeader.ts @@ -194,6 +194,125 @@ export function scanXMcpHeaderDeclarations( : { valid: false, reason: fault }; } +/** + * The `=?base64?…?=` sentinel wrapping a value that cannot be sent as a plain + * ASCII HTTP field value (SEP-2243 value-encoding rules). + */ +const BASE64_SENTINEL_PREFIX = "=?base64?"; +const BASE64_SENTINEL_SUFFIX = "?="; + +/** + * Convert a primitive argument value to its string form per the spec's + * type-conversion rules: strings pass through, booleans become lowercase + * `'true'`/`'false'`, integers/numbers become their decimal string. Non-finite + * numbers and integers outside the safe range are refused (returns `undefined`, + * meaning "do not emit a header for this value"). Anything non-primitive + * (object/array/null/undefined) also yields `undefined`. + */ +function mcpParamPrimitiveToString(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefined; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + return undefined; + } + return String(value); + } + return undefined; +} + +/** + * `true` when `s` cannot be safely represented as a plain ASCII HTTP field + * value (RFC 9110 §5.5): it is empty, contains a byte outside `0x20–0x7E`/`0x09`, + * has leading/trailing whitespace (which field parsing strips), or already + * matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" rule). + */ +function needsBase64(s: string): boolean { + if (s.length === 0) return true; + if ( + s.startsWith(BASE64_SENTINEL_PREFIX) && + s.endsWith(BASE64_SENTINEL_SUFFIX) + ) { + return true; + } + if (s !== s.trim()) return true; + for (let i = 0; i < s.length; i++) { + // Non-null: `i` is always in bounds, so `codePointAt` returns a number. + const c = s.codePointAt(i)!; + if (c === 9 || (c >= 32 && c <= 126)) continue; + return true; + } + return false; +} + +function utf8ToBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ""; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin); +} + +/** + * Encode a string value as an HTTP field value per SEP-2243: a value that is + * already a safe plain-ASCII field value passes through unchanged; anything + * else is wrapped as `=?base64?{b64-of-utf8}?=`. + */ +function encodeMcpParamValue(value: string): string { + return needsBase64(value) + ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` + : value; +} + +function valueAtPath(root: unknown, path: string[]): unknown { + let node: unknown = root; + for (const key of path) { + if (node === null || typeof node !== "object") return undefined; + node = (node as Record)[key]; + } + return node; +} + +/** + * Build the `Mcp-Param-{Name}` headers for one `tools/call` from validated + * `x-mcp-header` declarations and the call's `arguments`. A declaration whose + * value is `null` or absent is omitted (the spec's "client MUST omit the header" + * rows); a value that is not a primitive of the declared kind is omitted rather + * than emitted malformed. Faithful port of the SDK's internal helper (not part + * of its public surface), so the headers match what a conforming client sends. + */ +export function buildMcpParamHeaders( + declarations: XMcpHeaderDeclaration[], + args: Record, +): Record { + const out: Record = {}; + for (const decl of declarations) { + const raw = valueAtPath(args, decl.path); + if (raw === undefined || raw === null) continue; + const stringValue = mcpParamPrimitiveToString(raw); + if (stringValue === undefined) continue; + out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = + encodeMcpParamValue(stringValue); + } + return out; +} + +/** + * SEP-2243 `Mcp-Param-*` headers a `tools/call` must carry for a given tool and + * arguments. Returns `{}` when the tool declares no `x-mcp-header`, when its + * annotations are invalid (such a tool is excluded from `tools/list`), or when + * no declared argument has a mirrorable value. Callers attach the result to the + * `tools/call` request headers on a modern connection. + */ +export function mcpParamHeadersForTool( + tool: Tool, + args: Record, +): Record { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid || scan.declarations.length === 0) return {}; + return buildMcpParamHeaders(scan.declarations, args); +} + /** A tool the Inspector keeps, paired with its mirrored-header declarations. */ export interface MirroredHeaderParam { /** Dot-joined property path (e.g. `region` or `filter.city`). */ diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 527219ad..b53beb84 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -18,7 +18,10 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, } from "./types.js"; -import { scanXMcpHeaderDeclarations } from "../json/xMcpHeader.js"; +import { + scanXMcpHeaderDeclarations, + mcpParamHeadersForTool, +} from "../json/xMcpHeader.js"; // Re-export so v1.5 tests that do `import { InspectorClientOptions } from // "@inspector/core/mcp/inspectorClient.js"` keep resolving. export type { @@ -3304,6 +3307,52 @@ export class InspectorClient extends InspectorClientEventTarget { } } + /** + * Coerce the string-valued entries of a tool's arguments to the types its + * `inputSchema` declares (the Tools form hands everything over as text). + * Shared by the two `tools/call` entry points — {@link attemptToolCall} and + * {@link callToolStream} — so both put the SAME arguments on the wire. + */ + private convertStringToolArgs( + tool: Tool, + args: Record, + ): Record { + const stringArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + stringArgs[key] = value; + } + } + if (Object.keys(stringArgs).length === 0) return args; + return { ...args, ...convertToolParameters(tool, stringArgs) }; + } + + /** + * SEP-2243: mirror `x-mcp-header`-annotated arguments into `Mcp-Param-*` + * headers on a modern connection. The SDK only does this inside + * `client.callTool()` (and skips it in the browser), but we route + * `tools/call` through `client.request()` for manual MRTR driving (#1704), so + * we mirror ourselves. `Protocol.request` forwards `headers` (preserved + * across MRTR retry legs) to the transport, and the remote transport relays + * them to the backend's upstream send — issued server-side, where the browser + * skip doesn't apply. No-op on legacy/stdio (no annotations). + * + * Applied by BOTH `tools/call` entry points: a plain call + * ({@link attemptToolCall}) and a task-augmented one + * ({@link callToolStream}) — a strict modern server rejects either with + * `-32020` when the mirrored header is missing. + */ + private applyMirroredParamHeaders( + tool: Tool, + convertedArgs: Record, + requestOptions: RequestOptions, + ): void { + if (this.protocolEra !== "modern") return; + const paramHeaders = mcpParamHeadersForTool(tool, convertedArgs); + if (Object.keys(paramHeaders).length === 0) return; + requestOptions.headers = { ...requestOptions.headers, ...paramHeaders }; + } + /** * Run a single tools/call attempt: convert args, issue the request, validate, * and return a successful {@link ToolCallInvocation}. Throws on any error @@ -3326,17 +3375,7 @@ export class InspectorClient extends InspectorClientEventTarget { if (!client) { throw new Error("Client is not connected"); } - let convertedArgs: Record = args; - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertToolParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } + const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | undefined = @@ -3367,6 +3406,7 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?.progressToken, signal, ); + this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); // Route through the MRTR driver (`requestWithInputRequired`) so a modern // `input_required` result pauses at the pending-request UI and retries with // the user's answer (#1704). Both eras use `client.request` with @@ -3949,17 +3989,7 @@ export class InspectorClient extends InspectorClientEventTarget { throw new Error("Client is not connected"); } try { - let convertedArgs: Record = args; - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertToolParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } + const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | undefined = @@ -3999,6 +4029,9 @@ export class InspectorClient extends InspectorClientEventTarget { // emit requestorTaskProgress) for task calls only, bypassing the toggle // that governs every other call path. const requestOptions = this.getRequestOptions(metadata?.progressToken); + // The task-augmented `tools/call` needs the same SEP-2243 mirroring as the + // plain one — a strict modern server rejects it with -32020 otherwise. + this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); if (this.progress) { const innerOnProgress = requestOptions.onprogress; requestOptions.onprogress = (progress: Progress) => { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index cfcec90d..4dcbf689 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -13,11 +13,12 @@ * on the browser path and reach the server through this proxy. * - The custom `Mcp-Param-*` headers are gated on a non-browser environment in * the SDK (`detectProbeEnvironment() !== "browser"`, avoiding a CORS - * preflight), so they are NEVER built in the browser — the proxy would - * forward them, but the SDK never creates them here. A tool with an - * `x-mcp-header` annotation is therefore uncallable from the web client - * against a strict modern server (it answers `-32020 HeaderMismatch`); such - * tools work from the Node CLI/TUI, where mirroring is active. + * preflight), so the SDK never builds them here. The Inspector builds them + * itself instead (`mcpParamHeadersForTool`, #1846) — but they do NOT travel + * this path: they ride the transport's per-send `headers` through + * `/api/mcp/send`, where the Node backend applies them to the upstream + * request it issues. So an `x-mcp-header`-annotated tool IS callable from + * the web client against a strict modern server, same as from the CLI/TUI. */ export interface RemoteFetchOptions { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4f5c2a2d..4bbc518a 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -33,6 +33,7 @@ import type { } from "../types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; import { AuthChallengeError } from "../../../auth/challenge.js"; +import { MCP_PARAM_HEADER_PREFIX } from "../../../json/xMcpHeader.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, @@ -362,6 +363,28 @@ export function requestIdForSendWait( return undefined; } +/** + * Restrict client-supplied per-send headers to SEP-2243 `Mcp-Param-*` mirroring + * with string values. The sanctioned channel for arbitrary upstream headers is + * the server's configured `settings.headers`; this per-send channel must not let + * a client inject other headers (e.g. `Authorization`) onto the upstream fetch. + */ +export function mcpParamHeadersOnly( + headers: unknown, +): Record | undefined { + if (headers === null || typeof headers !== "object") return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if ( + typeof value === "string" && + key.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX.toLowerCase()) + ) { + out[key] = value; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { @@ -705,7 +728,7 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { sessionId, message, relatedRequestId } = body; + const { sessionId, message, relatedRequestId, headers } = body; if (!sessionId || !message) { return c.json({ error: "Missing sessionId or message" }, 400); } @@ -730,8 +753,14 @@ export function createRemoteApp( void responseWait.catch(() => {}); } try { + // SEP-2243: apply the client's mirrored headers to the upstream request, + // restricted to the `Mcp-Param-` prefix so a client can't inject other + // upstream headers. The StreamableHTTP transport merges these onto the + // outbound fetch; other transports ignore unknown send options. + const paramHeaders = mcpParamHeadersOnly(headers); await session.transport.send(message, { relatedRequestId: relatedRequestId as string | number | undefined, + ...(paramHeaders && { headers: paramHeaders }), }); if (responseWait) { await responseWait; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 9f3ac419..03c92461 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -704,6 +704,9 @@ export class RemoteClientTransport implements Transport { ...(options?.relatedRequestId != null && { relatedRequestId: options.relatedRequestId, }), + // Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to + // apply to the upstream send; the browser can't set them cross-origin. + ...(options?.headers != null && { headers: options.headers }), }; const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 2b9c0e58..23bb6aa9 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -120,6 +120,11 @@ export interface RemoteSendRequest { message: JSONRPCMessage; /** Optional, for associating response with request (e.g. streamable-http) */ relatedRequestId?: string | number; + /** + * Per-send `Mcp-Param-*` headers (SEP-2243 mirroring). The backend applies + * them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix. + */ + headers?: Record; } export type RemoteEventType = From 3ac34e4092ba377b2db5f8b06502213596c307d8 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Fri, 31 Jul 2026 23:00:24 -0400 Subject: [PATCH 006/941] docs: post-swap branch model, PR policy, milestone rule, and Copilot review instructions (#1866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: correct post-swap branch model, PR policy, and stale gate/tree entries Part of the go-live phase-8 docs hygiene: everything that silently inverted when `main` became v2. Branch/release model — documented canonically in AGENTS.md and corrected everywhere it was restated: - AGENTS.md: replace the one-line "Base Branches" bullet with a table giving each branch its role (v2/main = develop, main = release, v1/main = maintenance), whether PRs target it, and which npm dist-tag it publishes. - README.md: the "Repo status" callout had it backwards, claiming `main` was the legacy v1 implementation. Also note that a v2 release is cut from `main` after the milestone merge, not from v2/main. - CONTRIBUTORS.md: the version table listed v2's base branch as `main`. PR policy: - Every PR must reference an issue, from anyone, no exceptions. - PRs are opened by repo maintainers only — org write access is not authorization. Everyone else files a detailed issue. - Branch names start with the target version segment (v2/…, v1/…). - Issues and PRs carry exactly one of `v1`/`v2` at creation; default `v2`. - Screenshots proving UI/TUI changes go in the gitignored pr-screenshots/. Corrections to stale content found while in there: - The TUI coverage bullet still described the Ink/App.tsx/hooks exclusion as interim pending #1501, which closed 2026-06-29; the gate now covers all of src/** with only tui-servers.ts excluded. - The completion checklist said `npm run validate` was the gate and covered "e2e tests", contradicting the mandatory-pre-push-gate section (npm run ci). - v1 PR base was given as `main`; it is `v1/main`. - Project tree omitted core/client, docs/, and scripts/ (README: core/client, docs/). core/client is under the coverage gate and imported by both App.tsx trees. Also adds the launcher and `npm run test:scripts` to the "run the tests" list and a tiers overview, since neither was mentioned. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: require a milestone on every new issue, defaulting to the current one Adds the rule alongside the version-label rule, since they fail the same way: an unmilestoned issue drops out of release planning as silently as an unlabeled one drops out of version filtering. - Set it at create time (`gh issue create --milestone ...`). - If the user didn't specify one, default to the current milestone — the open milestone with the nearest due date — rather than leaving it blank pending a decision. Includes the `gh api .../milestones` one-liner that identifies it. - Milestones are release buckets, so pick by when the work ships, not by size; sub-issues normally inherit their parent's milestone. Also widens the section's opening callout: an issue is not "created" until it is labeled, milestoned, AND boarded with a Status — four distinct steps, not three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: add .github/copilot-instructions.md and require mirroring into it GitHub Copilot reads .github/copilot-instructions.md when reviewing a PR. The repo had no such file, so Copilot reviewed without any of the conventions that actually govern this codebase — the ban on `any` and on unjustified double casts, the Mantine-first styling rules, the `.withProps()` extraction rule, the lib/utils split, test placement, and the per-file >=90% four-dimension gate. The new file is a review-focused distillation of AGENTS.md: the rules a reviewer would cite against a diff, plus a short "what to prioritize" list that puts correctness and security first (this backend spawns processes and proxies outbound requests). Deliberately omitted: board recipes and IDs, milestone and branch mechanics, release procedure, and the project tree — no reviewer cites those, and copying them would double the maintenance surface. AGENTS.md's "Keep documentation files up to date" section now requires mirroring review-relevant changes into it in the same PR, states that AGENTS.md stays the source of truth, and spells out what counts as review-relevant. There is no generation step and nothing detects drift, so the rule is the only thing keeping the two in sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: address Copilot review — v1 policy wording, stale V2 Go Live, typo - Align the v1 maintenance policy on "security and bug fixes only" in AGENTS.md and CONTRIBUTORS.md; both said "security fixes only" while the README callout, the branch table, and the board description said "security and bug fixes". - Drop the stale "or V2 Go Live" instruction from the work-begins step — that column no longer exists on board #28 (verified via `gh project field-list 28`), so the option id it implied would be rejected. Restore the removed-column id list the same edit dropped, now including V2 Go Live, and re-date the table to today's verification. - Fix "contains the the new version" typo. - Strip a stray trailing blank line at the end of AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: restore `inspector/` as the Project Structure tree root The tree is a filesystem layout — every child is a real directory (`clients/`, `core/`, `test-servers/`). Labeling its root `v2/main/` named a branch instead, which reads as a path that does not exist in a checkout and diverged from the identical tree in README.md, which still says `inspector/`. The branch model is documented in its own table under Repository & Project Boards; the tree does not need to restate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: settle the v1 policy wording as "security fixes only" Reverses the direction the Copilot round took in 33cc0366. That commit normalized the six v1-policy sites onto "security and bug fixes" because that was the majority wording — but majority is the wrong tiebreak here. The policy is set by what we told contributors: the #1819 backlog close note, posted to 120 closed PRs, says "v1 will receive security fixes only", and the five PRs retained for 1.0.5 are all security fixes. So the two sites that already said "security fixes only" were the correct ones, and the four that said otherwise are now aligned to them: - AGENTS.md: the `v1` label description, the branch-role table, and the v1 board line. - README.md: the "Repo status" callout. CONTRIBUTORS.md and AGENTS.md's Project Status bullet already read correctly and are unchanged. This is the substance of #1813, which is why that issue's PR is now reduced to its PR-template-link half — the wording work landed here to avoid two PRs editing the same lines in opposite directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: point the AGENTS.md policy link at CONTRIBUTING.md This PR rewrites the paragraph holding the only in-repo link to the contributing policy, so it owns that line. #1884 renames the file CONTRIBUTORS.md -> CONTRIBUTING.md; having #1884 also edit this line would guarantee a one-line conflict between the two. Writing the new filename here instead makes #1884 a pure `git mv` with no overlapping line, so neither PR conflicts with the other. Between this merging and #1884 merging, the link points at a filename that doesn't exist yet. That window is deliberate and bounded — this PR is first in the merge order (#1821) and #1884 is third. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/copilot-instructions.md | 98 ++++++++++++++++++++++++ AGENTS.md | 127 +++++++++++++++++++++----------- CONTRIBUTORS.md | 42 +++++++++-- README.md | 6 +- 4 files changed, 222 insertions(+), 51 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..092a0c6b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,98 @@ +# Copilot review instructions — MCP Inspector + +> **`AGENTS.md` is the source of truth.** This file is a review-focused distillation of it, kept in sync by hand — see "Keep documentation files up to date" in `AGENTS.md`. Where the two disagree, `AGENTS.md` wins, and the drift is a bug worth flagging in review. + +The Inspector ships as one package with three clients (**Web**, **CLI**, **TUI**) over a shared `core/`, consumed via the `@inspector/core` build-time alias. v2 is **not** an npm workspace: the root and each `clients/*` keep their own `package.json` and `node_modules`. + +## TypeScript + +- **Never use `any`.** Not in types, not in casts, not in generics. +- **Never suppress errors to satisfy the linter or compiler** — no disabling `no-unused-vars` / `no-explicit-any` in config, and no `// @ts-nocheck` or `// @ts-ignore` (`@typescript-eslint/ban-ts-comment` rejects these across every surface). +- **Avoid double casts (`as unknown as T`).** They erase all type safety and usually mean the real type is being worked around. Prefer a type guard, a narrower single cast, or fixing the underlying type. If genuinely unavoidable (a documented gap in a third-party type, or bridging structurally-identical shapes TS can't relate), it **must** carry an inline comment justifying why it's safe and why nothing better exists. An unjustified `as unknown as` is not acceptable in review. +- Prefer inference, type guards, and precise annotations over assertions. +- An `_` prefix is the intentionally-unused marker (`argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern`). + +## React and UI (web client) + +The web client is built from **presentational ("dumb") components** — they take data and callbacks as props and hold display logic only. No data fetching or client state inside them; state comes from the `@inspector/core` hooks wired near the top of the tree. A component that reaches for a store or fetches directly is a review finding. + +Styling is **Mantine-first**, in this strict order of preference: component props → theme variants → CSS classes (last resort). + +- **Never use inline styles.** +- **Never use raw color literals** — no hex (`#ddd`), no `rgba()`. Use the `--inspector-*` CSS custom properties from `App.css :root` (e.g. `c: 'var(--inspector-text-primary)'`). If no token fits, add one to `:root` first. +- **Avoid `div` and bare HTML for layout.** Use Mantine `Box`, `Group`, `Stack`, `Flex`, `Paper`. +- **Never add a CSS class when the styles can be component props or a theme variant.** Flat CSS properties (margin, padding, background, border, color, font-size) belong in the theme (`src/theme/<Component>.ts`, via `Component.extend()`). `App.css` may contain **only** what the theme cannot express: `@keyframes`, pseudo-selectors (`:hover`, `:focus`), cross-component hover relationships, nested child selectors for third-party HTML output, and styles for native elements (`img`, `iframe`). +- When a theme variant needs a class for nested/pseudo selectors, assign it via `classNames` in the theme extension — never a manual `className` in JSX for theme-styled components. + +### The `.withProps()` rule + +**Declare a named subcomponent constant via `.withProps()` whenever an inline Mantine element carries two or more _static_ props.** This applies to single-use elements too — "it's only used once" is not an exemption. + +- **Static** = a literal configuring **styling, layout, or behavior**: `size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`. +- **Not counted:** dynamic props (`value`, `on*`, `children`, `key`, `ref`, anything whose value is a variable) — pass these at the call site; and per-instance **content/accessibility** literals (`label`, `description`, `placeholder`, `title`, `aria-label`, `role`) — these never by themselves trigger extraction. + +```tsx +const CardContent = Group.withProps({ + flex: 1, + align: "flex-start", + justify: "space-between", + wrap: "nowrap", +}); +``` + +**Legitimate exceptions** (each stays inline, with a one-line comment saying why): + +- **`Box`** — does not support `.withProps()`. Use `Group`/`Stack`/`Flex`/`Text`/`Paper`/`UnstyledButton`/`Image` instead, chosen by purpose. A `Box` that genuinely needs a non-flex primitive (`component="iframe"`, `display="grid"`) stays inline. +- **`Accordion`** — a compound, `multiple`-discriminated generic; `.withProps()` loses its JSX call signature and fails to type. +- **Headless, non-`factory()` components** such as `Transition` — no Styles API, so no `.withProps` static at all. +- **`data-*` attributes** — not part of a component's typed props, so excess-property-checked out of a `withProps` literal. Pass at the call site. +- **Anything that isn't a Mantine factory component** — a `react-icons` glyph, another library's component, or a first-party plain `export function`. + +### Theme files vs. element components + +Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) customize a Mantine primitive **app-wide**. Element components (`src/components/elements/`) add **domain semantics** on top of primitives. + +- Element components import from `@mantine/core`, **not** from `src/theme/` — the theme layer is applied transparently by the provider. +- **Never push domain-specific variant logic into theme files** (annotation types, transport types, …). Domain variants belong to the element component that owns those semantics. + +## Where code goes (web client) + +**`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem it's `lib`; if it's a pure transform it's `utils`. + +- `src/utils/` — pure, side-effect-free. Also: pure shared domain types and their constructors; diagnostic `console.warn`/`error` does **not** count as a side effect; type-only imports from `@inspector/core`, and re-exporting pure functions/constants from core, are both fine. +- `src/lib/` — infrastructure, integration, stateful adapters: composes subsystems, wraps the core **runtime**, touches DOM / `window` / `sessionStorage`, or produces side effects. +- Cross-directory imports go **one way: `lib → utils`**, never the reverse. +- `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types. +- ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories. + +## Tests and the coverage gate + +- **All new or modified code needs tests.** The per-file gate is **≥ 90% on all four dimensions** — lines, statements, functions, **and branches** — enforced in CI for `clients/{web,cli,tui,launcher}` and the gated `core/` runtime. +- **Never lower the gate** to accommodate an unreachable branch. Annotate at the source with a justified `/* v8 ignore … -- <reason> */`. Acceptable reasons: happy-dom-inherent paths (Mantine portal mounts, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards. Anything else is a missing test. +- **Suppress expected error output** from the console in tests that exercise error paths. + +### Test placement + +- **Web:** side-by-side by default — `<Name>.test.tsx` next to the source. A web-owned test under `src/test/` instead of beside its source is a bug. `src/test/` is only for what can't be co-located: tests of the repo-root `core/` package (`src/test/core/…`, mirroring core's layout), the `integration` project (`src/test/integration/…` — placement _is_ the manifest), and shared test infrastructure. +- **CLI / TUI / launcher:** **all** tests live in a top-level `__tests__/` directory. A co-located `src/**/*.test.*` lands in no tsconfig project and fails `verify:typecheck-coverage`. + +### Rendering components in tests + +- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. Never hand-roll a bare `MantineProvider` — that reintroduces a real failure class where a `Transition`/`Modal` timer fires after happy-dom tears down `window` and fails the entire run. +- For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider. +- Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself. + +## Gates and PR hygiene + +- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. +- **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. +- Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. + +## What to prioritize in review + +1. Correctness and security — this backend spawns local processes and proxies outbound requests, so anything touching auth, origin validation, host binding, or the proxy's SSRF controls deserves close reading. +2. Type-safety violations (`any`, suppressions, unjustified double casts). +3. Missing or thin tests against the ≥90% four-dimension gate, and modules placed outside the gated directories. +4. Mantine convention violations — inline styles, raw colors, unnecessary CSS classes, missing `.withProps()` extraction. +5. Docs that contradict the change. diff --git a/AGENTS.md b/AGENTS.md index 5a5d48b8..f94fd60c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ This is an application for inspecting MCP servers. Has three incarnations, Web, ## Project Structure ``` -inspector/ +v2/main/ ├── clients/ │ ├── web/ # Web client (Vite + React + Mantine) │ │ ├── src/ # Browser source (React app, hooks, components) @@ -40,6 +40,13 @@ inspector/ │ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, │ │ │ # runner-interactive-oauth loopback callback flow) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) +│ ├── client/ # Install-level client config (`client.json`): browser-safe +│ │ # parse/validate (config-parse.ts) + Node load/save +│ │ # (config.ts, node-persistence.ts), the remote backend +│ │ # (remote.ts), secrets (secrets.ts), and runner.ts. +│ │ # Consumed by both App.tsx trees (web + tui); gated by +│ │ # the web coverage `include`, tests in +│ │ # clients/web/src/test/core/client/. │ ├── json/ # JSON utilities and parameter/argument conversion │ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header` │ │ # annotation scan/validation + mirrored-param @@ -82,6 +89,16 @@ inspector/ │ # in clients/web/vite.config.ts points at build/index.js │ # (not src/) so `getTestMcpServerPath()` returns a `.js` path. │ # tsconfig.test.json keeps paths pointing at src for typecheck. +├── docs/ # Task-oriented guides (mcp-server-configuration.md, +│ # mcp-app-review.md, launcher-config-consolidation-plan.md, +│ # images/). Linked from the root README. +├── scripts/ # Root build/verify tooling: install-clients.mjs (the +│ # postinstall cascade), the smoke-*.mjs runners, +│ # verify-build-gate / verify-format-coverage / +│ # verify-typecheck-coverage, pack-and-verify.mjs, +│ # and lib/ shared helpers. Prettier-gated via +│ # `format:check:scripts`; its own pure parsers are +│ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification ... ``` @@ -96,39 +113,21 @@ v2 is **not** an npm workspace — each client under `clients/*` keeps its own ` After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. -## Repository & Project Board - -- **Repo**: https://github.com/modelcontextprotocol/inspector.git -- **Base Branches**: v2/main (active), main (v1). v1.5/main is merged into v2/main and no longer takes new work. -- **Project Boards**: - - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all current work goes here) - - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (existing inspector version, no new activity except security and bug fixes) - ## Contributing -External contributions are accepted as **issues, not pull requests** — -maintainers handle design and implementation through a prompt-driven workflow. -If you've already built a change locally, share the **prompt** you used, not a -diff. See [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) for the full policy. +External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow. +If you've already built a change locally, share the **prompt** you used and screenshots if applicable, not a diff. See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full policy. + +**This applies to org members with write access too, not just outside contributors.** Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the **repo maintainers** only. Anyone else — including organization members whose write access makes it technically possible — opens a **detailed issue** instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff. + +**Every PR must reference an issue. No exceptions, regardless of who opens it.** The PR body's first line is `Closes #<ISSUE_NUMBER>` (see the [Issue-driven Work Style](#issue-driven-work-style) rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature. ## Project Status and Direction -* The main branch currently contains the legacy version of the Inspector, which we are accepting bug fixes and minor improvement PRs for. +* The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The v1.5/main branch was the intermediate version of the Inspector, where the shared logic between the three incarnations of the Inspector was extracted into a core subsystem with InspectorClient class as the common entry point. It also included the TUI, a refactored CLI, and streamlined launcher. The branch still exists but is **frozen** — it takes no new work. It is kept as a reference point (e.g. for tracking down a regression introduced by the merge into v2/main), so do not delete it. +* The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. -* The v2/main branch currently contains the new version of the web Inspector, composed of "dumb" components which accept data and callbacks as props and contain only display logic. - -The Launcher, TUI, CLI, and InspectorClient from v1.5/main have been merged into v2/main. InspectorClient is wired up to the new web Inspector. Eventually, we will replace main with v2/main, eliminating the legacy implementations. - -## Web backend auth token - -The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The browser recovers that token from three sources, in priority order (see `App.tsx` `getAuthToken()`): - -1. `window.__INSPECTOR_API_TOKEN__` — injected into `index.html` on every page load by the backend (the dev Vite plugin via `transformIndexHtml`, the prod Hono server on the `/` route), both routed through `clients/web/server/inject-auth-token.ts`. This is what makes a bare-URL reload, a bookmark, or a cleared `sessionStorage` keep working. -2. `?MCP_INSPECTOR_API_TOKEN=…` query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs. -3. `sessionStorage` — backstop for navigations that land without either of the above. - -Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. +* The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. ## Maintenance Rules @@ -137,30 +136,61 @@ Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the gl - When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details. - When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well. - When rules for implementation and testing change, update this file AGENTS.md +- **Mirror review-relevant changes into [`.github/copilot-instructions.md`](.github/copilot-instructions.md).** That file is what GitHub Copilot reads when it reviews a PR, and it is a hand-maintained **distillation** of this one — there is no generation step and nothing detects drift, so it goes stale silently and Copilot then reviews against rules we no longer hold. + - **AGENTS.md remains the source of truth.** Never edit `copilot-instructions.md` alone to change a rule; change it here first, then mirror. + - **Review-relevant** means anything a reviewer would cite against a diff: the TypeScript rules, the Mantine/React conventions (including the `.withProps()` rule and its exceptions), the `lib` vs `utils` split, test placement, the ≥90% coverage gate and the `v8 ignore` policy, the `renderWithMantine` requirement, and the PR hygiene rules (`Closes #N`, version label). Changing any of these means updating both files in the **same PR**. + - **Not review-relevant, and deliberately absent** from the mirror: the board recipes and their IDs, milestone and branch-naming mechanics, release and publishing procedure, and the project-structure tree. Copying those in would double the maintenance surface for content no reviewer cites. + - Keep it a **distillation, not a copy** — it is read on every review, so length has a cost. Prefer tightening the summary over pasting a section wholesale. ### Issue-driven Work Style All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is BOTH labeled `v2` AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag; the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All three are distinct steps; do all three (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All four are distinct steps; do all four (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. -- **Assign the issue to its creator.** When you create an issue, assign it to the user it is created on behalf of (`gh issue create --assignee @me ...`, or `--assignee <login>`). Board items should never be unassigned. -- **Label by version.** New issues and PRs must carry the label matching the target board / branch: - - `main` → `v1` - - `v2/main` → `v2` +- **Label by version — every issue and every PR, no exceptions.** Each one carries **exactly one** of `v1` or `v2` at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible. + - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) + - `v2` — work targeting `v2/main` (active development; the default for anything new) - Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. + Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status. - **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. +- **Every new issue gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. Get the current milestone with: + + ```sh + # Open milestones, soonest due date first — the first row is the current one. + gh api repos/modelcontextprotocol/inspector/milestones --jq \ + 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"' + ``` + + Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by *when the work ships*, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. - When work begins, create a feature branch and set the item's Status to **In Progress** (or **V2 Go Live** for a card in the go-live phases, #1804). +- **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - - Run format, lint, typecheck, build, and test — ensure all checks pass - - Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - - **Link the PR to its issue.** The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) + - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. + - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** + - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. + - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `248a3910`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. +## Repository & Project Boards + +- **Repo**: https://github.com/modelcontextprotocol/inspector.git +- **Base Branches** — three branches, three distinct roles. Target the one matching the work; never open a PR against `main`. + + | Branch | Role | PRs target it? | Publishes to | + | --- | --- | --- | --- | + | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | + | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | + | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | + + So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). +- **Project Boards**: + - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) + - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) + #### V2 board (#28) `gh` recipes The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and Status field id are stable. **The Status *option* ids are NOT stable — they are regenerated whenever the Status field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: @@ -175,23 +205,22 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | Project node ID | `PVT_kwDOCt2Azc4BJVxt` | | Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | -Status option IDs (`--single-select-option-id`) — **last verified 2026-07-27**. `V2 Go Live` was added via the web UI on 2026-07-27 for the go-live phases (#1804); the other four IDs were unchanged by that addition, confirming the web-UI path is safe (see the ⚠️ hazard below). Removed columns whose IDs are now rejected: `SDK V2 + New Spec` (`1bbb6f57`), `Building CLI / TUI / CORE` (`4ac261ee`), `Building Web` (`c28da89f`), `MCP Apps Extension` (`73d0b807`). +Status option IDs (`--single-select-option-id`) — **last verified 2026-07-27**. | Status | Option ID | | --- | --- | | Todo | `fbdaf21e` | -| V2 Go Live | `b3a6966e` | | In Progress | `195df262` | | In Review | `159c8a02` | | Done | `248a3910` | -Use **Todo** for approved-but-not-started work, **In Progress** for general active work (regardless of surface), **V2 Go Live** for cards in the go-live phases (#1804), **In Review** once a PR is open, and **Done** on merge. +Use **Todo** for approved-but-not-started work, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. > ⚠️ **Never add, rename, or remove a board column (Status option) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans the Status of every card on the board** (all items go blank) *and* invalidates every option id in the table above. This has happened once (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: > 1. **Add/rename/remove a column in the GitHub web UI** (Project #28 → Status field settings). This preserves ids of untouched options and never orphans cards. > 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. Verify afterward that no card lost its Status. > -> `gh project item-add` and `gh project item-edit` are always safe — they set a card's value and never touch the field schema. When option ids change for any reason, **re-verify and update the table above** (and the `248a3910` / `195df262` / `159c8a02` references in the recipes below and the merge step above). +> `gh project item-add` and `gh project item-edit` are always safe — they set a card's value and never touch the field schema. When option ids change for any reason, **re-verify and update the table above** (and the references in the recipes below and the merge step above). ```sh # 1. Add an issue to the board — prints the item id (PVTI_…); capture it. @@ -229,12 +258,15 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i - Run unit tests with `npm run test` (or `npm run test:watch` during development) from `clients/web/` - Run CLI tests with `npm run test` from `clients/cli/` (builds test-servers + CLI bin first via `pretest`) - Run TUI tests with `npm run test` from `clients/tui/` +- Run launcher tests with `npm run test` from `clients/launcher/` +- Run the root tooling's own tests with `npm run test:scripts` from the root — `node --test "scripts/**/*.test.mjs"`, node's built-in runner (the root has no vitest harness by design). A new `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`; keep the filename `*.test.mjs`, since `node --test` silently **skips** a file its glob misses and still exits 0 (`verify:typecheck-coverage` guards against exactly that). +- **The test tiers, shallowest first:** unit (`test`, per client) → web integration (`test:integration`, real transports/servers) → out-of-process (`clients/cli/__tests__/e2e.test.ts`, spawns the built binary) → smokes through the built launcher (`npm run smoke`) → Storybook play functions (`test:storybook`) → the published-tarball check (`npm run pack:verify`, local/release only — needs network). `validate` runs the per-client `test` scripts — so web **unit** plus cli's out-of-process `e2e.test.ts` (it's part of cli's `test`), but **not** web's integration project, which runs inside the `coverage` gate. Everything from `smoke` rightward is `npm run ci` only, and is described under [Mandatory pre-push gate](#mandatory-pre-push-gate). - The repo root has no aggregate `test` script — each client self-validates, so run `npm run validate` from the root (all clients, fast) or `cd clients/<name> && npm run validate` (one client). Each client still exposes its own `test` / `test:coverage` for quick iteration. - **`validate` is fast: it runs `test`, not `test:coverage`.** The coverage gate (slower — adds v8 instrumentation, and for web the integration project) is a **separate** top-level `npm run coverage` (and per-client `coverage:web` / `coverage:cli` / `coverage:tui` / `coverage:launcher`, each delegating to that client's `test:coverage`). Run `npm run coverage` when you want to reproduce the gate locally before pushing. **CI runs `coverage`** on every push (#1550): the per-file ≥90 gate is CI-enforced, so a PR that drops any file below 90 on lines/statements/functions/branches fails the job. CI runs `validate` (fast) for format/lint/build/unit tests, then `coverage` for the instrumented gate. Because web's `test:coverage` already runs the integration project, CI has no separate `test:integration` step — the integration paths are exercised inside the coverage gate. - Each client's `test:coverage` enforces a **uniform per-file gate of ≥ 90 on all four dimensions** — lines, statements, functions, and branches — across `clients/web`, `clients/cli`, `clients/tui`, and `clients/launcher` (CI enforces this gate). This is the result of a codebase-wide audit: the branch floor was first lifted 50 → 70 for web (#1271), then the whole gate raised to 90 with real tests added for every outlier. Genuinely-unreachable branches are **not** waved through by lowering the gate — they are annotated at the source with a justified `/* v8 ignore … -- <reason> */` comment. Acceptable reasons are happy-dom-inherent paths (Mantine portal mount points, `useMediaQuery` fallbacks, `typeof window` SSR guards), React StrictMode effect-replay blocks, and provably-dead defensive guards (e.g. a `?? fallback` for a value the types guarantee non-null, or a `Select.onChange` receiving a value outside the allowed list). New code must clear 90 on every dimension; reach for a justified `v8 ignore` only when a branch is genuinely impossible to exercise. The web coverage `include` (in `clients/web/vite.config.ts`) covers the shared `core/` runtime consumed by the browser — `core/mcp`, `core/react`, `core/auth`, `core/storage`, `core/logging`, `core/node`, **`core/json`, and `core/client`** (the last two folded in by #1689). When adding a `core/json/*` or `core/client/*` module, its tests live under `clients/web/src/test/core/…` and are gated the same ≥90 way. - The **same per-file gate** is enforced for the CLI and TUI (#1484), not just web: - **CLI** (`clients/cli`): tests run **in-process** by importing `runCli()` (see `__tests__/helpers/cli-runner.ts`) so `clients/cli/src` is measured under v8 instrumentation. A thin out-of-process layer (`__tests__/e2e.test.ts` + `scripts/smoke-cli.mjs`) still spawns the built binary for the shebang/`process.exit` paths; `src/index.ts` (binary bootstrap) is the only coverage exclusion. `commander` uses `.exitOverride()` so a parse error throws instead of tearing down the test worker. - - **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it. + - **TUI** (`clients/tui`): the gate now covers **all of `src/**`, React surface included** — the former interim exclusion of the Ink components, `App.tsx`, and `hooks/` was lifted in #1501. Components mount through `ink-testing-library` with the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`, `App.tsx` mounts against a controllable mock of the `@inspector/core` surface, and keypresses are driven through stdin. The **only** coverage exclusion left in `clients/tui/vitest.config.ts` is `src/tui-servers.ts` — a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured in `core/` via the web suite; `tui-servers.test.ts` still exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic under `clients/tui/src`, React or not, is held to the gate automatically. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. - **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* @@ -351,3 +383,14 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - Element components add domain-specific semantics on top of Mantine primitives. For example, `AnnotationBadge` maps domain concepts (audience, destructive, longRun) to Mantine's styling primitives (color, variant). Storybook documents these domain components for designers and developers. - Element components MUST import from `@mantine/core`, NOT from `src/theme/`. The theme layer is applied transparently by the provider — elements do not need to know about `Theme<Name>` constants. - NEVER push domain-specific variant logic (e.g., annotation types, transport types) into theme files. Domain variants belong in the element component that owns those semantics. Theme files are for styling that applies to the Mantine primitive globally. + +## Web backend auth token + +The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The browser recovers that token from three sources, in priority order (see `App.tsx` `getAuthToken()`): + +1. `window.__INSPECTOR_API_TOKEN__` — injected into `index.html` on every page load by the backend (the dev Vite plugin via `transformIndexHtml`, the prod Hono server on the `/` route), both routed through `clients/web/server/inject-auth-token.ts`. This is what makes a bare-URL reload, a bookmark, or a cleared `sessionStorage` keep working. +2. `?MCP_INSPECTOR_API_TOKEN=…` query string — the URL the launcher banner prints; kept as a fallback for pasted full URLs. +3. `sessionStorage` — backstop for navigations that land without either of the above. + +Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. + diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 595f2847..df3bda45 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -8,7 +8,9 @@ form we can act on quickly and consistently. **We accept issues, not pull requests.** Design and implementation are done by the maintainers. If you've already built a fix or feature locally, share **the -prompt you used** to produce it — not the source code. +prompt you used** to produce it — not the source code. This applies to everyone +outside the repo maintainers, including organization members who happen to have +write access to this repository. ## Why this policy exists @@ -32,6 +34,26 @@ than an issue, see [Want to work on the Inspector with us?](#want-to-work-on-the-inspector-with-us) below — we'd rather bring you into the workflow than turn you away. +## Who opens pull requests + +Pull requests against this repository are opened by the **repo maintainers** +only. That includes organization members with write access: being able to push +a branch here isn't the same as being asked to — the constraint is the workflow +described above, not permissions, so the same policy applies whether or not +GitHub would let you click the button. + +If you're not a repo maintainer, open a **detailed issue** instead and a +maintainer will pick it up. If you've already prototyped the change locally, +say so in the issue and include the prompt you used (see +[If you've already fixed it locally](#if-youve-already-fixed-it-locally)) — +that's the fastest path from your work to a merged change. + +**Every pull request references an issue**, including the maintainers' own. The +PR body's first line is `Closes #<ISSUE_NUMBER>`. Work is tracked on the project +board through issues, so a PR without one is invisible to the board — which is +why a well-formed issue is the useful contribution here, and why writing one is +never wasted effort. + ## How to contribute a bug report or feature request Open a well-formed issue describing the bug or the feature you have in mind. @@ -46,18 +68,22 @@ The Inspector is maintained across two versions, each with its own base branch and version label. File your issue against the version your report or request targets: -| Version | Base branch | Label | -| ------- | ----------- | ----- | -| v1 | `v1/main` | `v1` | -| v2 | `main` | `v2` | +| Version | Base branch | Label | npm tag | +| ------- | ----------- | ----- | ------------ | +| v1 | `v1/main` | `v1` | `v1-latest` | +| v2 | `v2/main` | `v2` | `latest` | - **v1** (`v1/main`) is the legacy Inspector — it takes **security fixes - only**. -- **v2** (`main`) is where all current work happens — when in doubt, target v2. + only**, and is published straight from that branch to the `v1-latest` npm + tag (`npx @modelcontextprotocol/inspector@v1-latest`). +- **v2** (`v2/main`) is where all current work happens — when in doubt, target + v2. `v2/main` is the develop branch; it is merged into `main` at milestone + releases, and `main` is what publishes the `latest` npm tag. Nothing targets + `main` directly. **Label by version.** Every issue (and the PRs maintainers open for it) must carry the label matching the target branch — `v1` for `v1/main` and `v2` for -`main`. This mirrors the "Label by version" convention documented in +`v2/main`. This mirrors the "Label by version" convention documented in [`AGENTS.md`](./AGENTS.md). ## If you've already fixed it locally diff --git a/README.md b/README.md index 235be143..74eb0ce8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI ``` -> **Repo status.** This is the **v2** line of the Inspector (branch `v2/main`). The `main` branch is the legacy v1 implementation (bug fixes only). v2 will eventually replace `main`. See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. +> **Repo status.** This is the **v2** line of the Inspector. Active development happens on **`v2/main`** (the develop branch — all v2 PRs target it), which is merged into **`main`** at milestone releases; `main` is the default branch and holds the latest released v2, published to the npm `latest` tag. The legacy **v1** line lives on **`v1/main`** — security fixes only, published straight from that branch to the npm `v1-latest` tag (`npx @modelcontextprotocol/inspector@v1-latest`). See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. ## Project layout @@ -29,6 +29,7 @@ inspector/ │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) │ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends) +│ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets │ ├── json/ # JSON + parameter/argument conversion utilities │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import @@ -37,6 +38,7 @@ inspector/ │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests ├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) +├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) └── README.md # You are here @@ -182,6 +184,8 @@ Publishing is automated by two release-gated jobs in [`.github/workflows/main.ym - **`publish`** — the npm package. Runs `npm run pack:verify` as the pre-publish gate, asserts the release tag matches the root `package.json` version, then `npm publish --access public --provenance` — a single `npm publish` (v2 is not an npm workspace, so there is no v1-style `publish-all`/`--workspaces`), with a signed provenance attestation via GitHub OIDC (`id-token: write`, `environment: release`, `NPM_TOKEN`). - **`publish-github-container-registry`** — the container image (see [Docker](#docker)). +A v2 release is cut from **`main`**, after the milestone's work has been merged there from `v2/main` — not from `v2/main` itself. (The v1 line releases independently from `v1/main` to the `v1-latest` tag and never touches `main`; see [Repo status](#mcp-inspector).) + Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: ```bash From 2c24b82e8ff8a5f0904424cac03aca2f5a460c47 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:01:19 -0400 Subject: [PATCH 007/941] docs: converge README Test servers section with main's restructure (#1880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: converge README Test servers section with main's restructure Transplants main's restructured "Test servers" section (#1836) onto v2/main — the "Serving the modern protocol era" and "Showcase configs" headings, the config table, and the per-config subsections — replacing the eight long paragraphs v2/main still carried. Folds in v2/main's #1846 correction: main's copy still claims Mcp-Param-* mirroring is skipped in the browser, which stopped being true when the Inspector took over building the mirrored headers itself. The blockquote callout keeps main's shape with the corrected wording. Scoped to that section only — the whole file is deliberately NOT copied from main, so #1866's repo-status callout and release-section edits (and every other v2/main-only line) are untouched. Shrinks the first v2/main -> main milestone merge from a whole-section conflict to a single 3-line hunk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: point the #1846 reference at the issue, not a nonexistent PR URL #1846 is the issue; the fix shipped as PR #1847. The callout's link used /pull/1846, which 404s. Per Copilot review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- README.md | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 74eb0ce8..a597b665 100644 --- a/README.md +++ b/README.md @@ -117,21 +117,128 @@ npm run test-servers:build # (from clients/web) → tsc -p test-servers, emits The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vite.config.ts`) points at `test-servers/build/index.js` so `getTestMcpServerPath()` resolves to a real `.js` path. -A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) +### Serving the modern protocol era -`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: the SDK only mirrors `Mcp-Param-*` inside `client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes `tools/call` through `client.request()` to drive MRTR manually, so it builds the mirrored headers itself (#1846) — on **every** client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So `get_weather` is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms. +A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler`: -`test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`). +- Set `transport.modern` in the JSON config — `true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict. +- Or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. -`test-servers/configs/pagination-http.json` is the **page-by-page fetch showcase** (#1721). It serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with `maxPageSize` of 4 for each, so every list paginates into three pages. Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the Tools/Resources/Prompts lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status; each click fetches the next 4 and appends them, and Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. +This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. -`test-servers/configs/advertised-extensions-http.json` is the **advertised-extensions showcase** (#1739). It serves `echo` (always) and a `get_weather` tool that is **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the connected client declared that extension in its `capabilities.extensions`. Connect (the Inspector advertises the Tasks extension by default) and the Tools list shows both `echo` and `get_weather`. Open **Server Settings → Advertised Extensions**, uncheck **Tasks (io.modelcontextprotocol/tasks)**, and reconnect: the client now advertises no extensions, the server never enables `get_weather`, and the Tools list shows only `echo`. This demonstrates the debugging knob — a server legitimately changing tool registration on what the client advertises. (Legacy stateful leg only; the modern per-request leg has no persistent `oninitialized`.) +### Showcase configs -`test-servers/configs/logging-legacy-http.json` and `test-servers/configs/logging-modern-http.json` are the **logging era-fork showcase** (#1629). Both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level; the legacy one is a plain streamable-HTTP server (`logging/setLevel` era) and the modern one sets `transport.modern: true`. Connect to the legacy server and open the **Logs** tab to get the session-scoped **Set Active Level** selector + **Set** button; calling `send_notification` streams the log into the panel. Connect to the modern one with **Protocol Era = Modern** and the same tab instead shows the **Log Level per Request** control — pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body); calling `send_notification` then streams the log into the panel over the request's SSE response. Set the control back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`): on the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream. +Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's _receive_ path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.) +| Config | Demonstrates | Issue | +| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| `modern-mrtr-http.json` | A single MRTR round-trip | — | +| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | +| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | +| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | +| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | +| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | +| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | +| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | -`test-servers/configs/tasks-legacy-http.json` and `test-servers/configs/tasks-modern-http.json` are the **Tasks era-fork showcase** (#1631). The legacy server advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets — connect to it, run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. The modern server sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving the `modern_task` / `modern_input_task` tools. Connect with **Protocol Era = Modern**: the **Tasks** tab is now gated on the negotiated extension (not `capabilities.tasks`). Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). Run `modern_input_task` and the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal; answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle; `tasks/get`/`update`/`cancel` ride a raw-wire request channel with the full modern envelope), and the test server serves `tasks/*` from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them `-32601`). The Tasks tab's **Refresh** re-polls the handles already known to the client (modern has no server-side task list). +#### MRTR + +`modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`. + +The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes. Useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. + +`mrtr-showcase-http.json` bundles every MRTR preset in one server: + +| Preset | Behavior | +| --------------- | ------------------------------------------------------------------------------ | +| `mrtr_confirm` | Single round | +| `mrtr_two_step` | Two elicitation rounds via `requestState` | +| `mrtr_sample` | Embedded sampling → the Sampling panel | +| `mrtr_roots` | Embedded `roots/list`, auto-answered silently from configured roots (no modal) | +| `mrtr_edge` | An `inputRequests`-only round, then a `requestState`-only round | +| `mrtr_loop` | Never completes → trips the `MRTR_MAX_ROUNDS` bound | + +> The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement. + +#### Network tab — standardized headers and error taxonomy + +`modern-network-http.json` covers SEP-2243 / SEP-2575. It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation, so a modern client mirrors it to `Mcp-Param-City`. + +It also serves four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status plus JSON-RPC error body: + +| Tool | Response | +| ----------------------------- | ---------------------------------------- | +| `trigger_header_mismatch` | `400` / `-32020` | +| `trigger_missing_capability` | `400` / `-32021` | +| `trigger_unsupported_version` | `400` / `-32022` (with `data.supported`) | +| `trigger_method_not_found` | `404` / `-32601` | + +Open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. + +> **`Mcp-Param-*` mirroring is built by the Inspector, not the SDK.** The SDK only mirrors inside `client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes `tools/call` through `client.request()` to drive MRTR manually, so it builds the mirrored headers itself ([#1846](https://github.com/modelcontextprotocol/inspector/issues/1846)) — on **every** client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So `get_weather` is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms. + +#### `x-mcp-header` in the Tools tab + +`xmcpheader-modern-http.json` serves: + +- `echo` — plain tool. +- `get_weather` — a **valid** `x-mcp-header: "City"` annotation on its `city` argument. +- `invalid_header_tool` — an annotation using the header name `"Bad Header"`. The space makes it an invalid RFC 9110 token, so the whole tool definition is invalid. +- `trigger_invalid_params` — answered with a real `-32602 Invalid params` error whose message is _not_ about a missing tool. + +Open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider with the reason on hover. A conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_. + +Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool, or **"Invalid Parameters"** otherwise (run `trigger_invalid_params`). + +#### Page-by-page fetching + +`pagination-http.json` serves 12 tools, 12 resources, and 12 prompts (presets `numbered_tools` / `numbered_resources` / `numbered_prompts`, `count: 12`) with a `maxPageSize` of 4 each, so every list paginates into three pages. + +Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. + +#### Advertised extensions + +`advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. + +1. Connect — the Inspector advertises the Tasks extension by default, so the Tools list shows both `echo` and `get_weather`. +2. Open **Server Settings → Advertised Extensions**, uncheck **Tasks (io.modelcontextprotocol/tasks)**, and reconnect. +3. The client now advertises no extensions, the server never enables `get_weather`, and the Tools list shows only `echo`. + +This is the debugging knob for a server legitimately changing tool registration based on what the client advertises. Legacy stateful leg only — the modern per-request leg has no persistent `oninitialized`. + +#### Logging, both eras + +`logging-legacy-http.json` and `logging-modern-http.json` both serve `logging: true` plus a `send_notification` tool that emits a `notifications/message` at a chosen level. The legacy one is a plain streamable-HTTP server; the modern one sets `transport.modern: true`. + +- **Legacy** — the **Logs** tab gives a session-scoped **Set Active Level** selector + **Set** button. Calling `send_notification` streams the log into the panel. +- **Modern** — the same tab instead shows **Log Level per Request**. Pick a level to opt in and the client stamps `_meta["io.modelcontextprotocol/logLevel"]` on every subsequent request (verify in the Network tab's request body). Calling `send_notification` streams the log over the request's SSE response. Set it back to **Off** and the same call is silently gated — the request omits the `logLevel` key, so the log never arrives. + +That gating is faithful to the spec ("a server MUST NOT emit `notifications/message` for a request that didn't opt in") because `send_notification` emits through the SDK's request-scoped, threshold-aware `extra.log` (`ctx.mcpReq.log`). On the modern leg it reads the per-request `logLevel` opt-in from the request envelope and drops the message when the client didn't opt in or the level is below the requested severity; on legacy it honors the session level from `logging/setLevel`. Because it emits through the request's `notify`, the modern response upgrades to SSE and the log rides the originating request's stream. + +#### Resource subscriptions, both eras + +`subscriptions-legacy-http.json` and `subscriptions-modern-http.json` both serve three `numbered_resources` with `subscriptions: true`. The legacy one also serves an `update_resource` tool; the modern one sets `transport.modern: true`. + +- **Legacy** — open a resource in the **Resources** tab and click **Subscribe**. The client sends `resources/subscribe` and the Subscriptions section lists the URI with no stream chrome. Call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. +- **Modern** — the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` plus the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`. The Subscriptions section then shows a stream-status badge (`Connecting…` → `Listening`) in its header, and reconnects by re-listing if the long-lived stream drops. + +The modern config deliberately **omits** `update_resource`. The SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the separate listen stream. More confusing than useful. + +So the live update-notification round-trip is demonstrated on the legacy (stateful-session) server, and the modern server is for the subscribe/listen/badge behavior. The Inspector's _receive_ path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way. + +#### Tasks, both eras + +**Legacy** (`tasks-legacy-http.json`) advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets. Run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. + +**Modern** (`tasks-modern-http.json`) sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving `modern_task` / `modern_input_task`. The **Tasks** tab is gated on the negotiated extension, not `capabilities.tasks`. + +- Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). +- Run `modern_input_task` — the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal. Answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. + +SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides. So the Inspector drives the extension itself — the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle, and `tasks/get` / `update` / `cancel` ride a raw-wire request channel with the full modern envelope. The test server serves `tasks/*` from an Express interceptor ahead of the SDK handler, since the SDK's modern leg would answer them `-32601`. + +The Tasks tab's **Refresh** re-polls the handles already known to the client — modern has no server-side task list. ## Building From 379a8a0a3a80d7a327277b59d764a35e70bc0d29 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:02:09 -0400 Subject: [PATCH 008/941] docs: rename CONTRIBUTORS.md to CONTRIBUTING.md so GitHub surfaces the policy (#1884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: rename CONTRIBUTORS.md to CONTRIBUTING.md (#1883) GitHub's contributing-guidelines banner — shown when someone clicks "New issue" or opens a pull request — keys on the exact filename CONTRIBUTING.md. Naming the file CONTRIBUTORS.md left that prompt dark precisely where the "open an issue, not a PR" policy needs to land. Renamed with `git mv` so history follows the file; contents are unchanged (its heading already reads "Contributing to MCP Inspector"). Updated the one in-repo reference, in AGENTS.md. The two links in .github/pull_request_template.md are deliberately untouched — PR #1879 makes them relative so they follow the rename. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: drop the AGENTS.md link edit; #1866 owns that line This PR edited the `See [CONTRIBUTORS.md](./CONTRIBUTORS.md)` link in AGENTS.md, which is the exact line #1866 rewrites — guaranteeing a one-line conflict when this merges after it. Handing that line to its owner instead: #1866 now writes the link as `CONTRIBUTING.md` directly, so this PR is a pure `git mv` and the two touch no common line. No conflict to resolve at merge time. The tradeoff is a short window between the two merges where AGENTS.md links to a filename that doesn't exist yet. That's deliberate and bounded: #1866 is first in the merge order and this is third. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- CONTRIBUTORS.md => CONTRIBUTING.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CONTRIBUTORS.md => CONTRIBUTING.md (100%) diff --git a/CONTRIBUTORS.md b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTORS.md rename to CONTRIBUTING.md From 7cc3c1276dfd4a5e2b219618e71cf462bfaa2cdb Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:02:55 -0400 Subject: [PATCH 009/941] docs: unpin the branch-pinned CONTRIBUTORS.md links in the PR template (#1879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: say "security fixes only" for v1 and unpin the PR-template links The v1 maintenance policy was stated two different ways across the docs — "security fixes only" in CONTRIBUTORS.md, "bug fixes" in README.md. The #1819 PR triage told 120 contributors that v1 is security-fixes-only, so that is the wording we keep. Also make the PR template's two CONTRIBUTORS.md links relative instead of pinned to blob/main/. `main` is release-only, so those resolved against the last released tree rather than the branch the reader is on. The remaining AGENTS.md occurrences are left alone deliberately: PR #1866 rewrites those exact lines and lands first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: drop the README change; #1866 owns that line The "Repo status" callout edit here patched `(bug fixes only)` → `(security fixes only)` on the callout as it exists on v2/main. But #1866 replaces that entire callout, because the surrounding sentence is false today: it still says "The `main` branch is the legacy v1 implementation", when `main` now holds the latest released v2. So this edit both collides with #1866 on the same line and, taken on its own, would preserve the false statement while correcting only the parenthetical. #1866 already sets the v1 policy wording to "security fixes only" across AGENTS.md and README (ee020ff2), so nothing is lost by dropping it here. This PR is now exactly its intended half: the two branch-pinned `blob/main/CONTRIBUTORS.md` links in .github/pull_request_template.md, made relative. That file is untouched by #1866, so the two PRs no longer overlap on any line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/pull_request_template.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index fac9cfff..029906db 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,13 +17,14 @@ → Open an issue and share the PROMPT(S) you used to generate it, not a diff. We'll reproduce it through our own workflow. - Full policy: https://github.com/modelcontextprotocol/inspector/blob/main/CONTRIBUTORS.md + Full policy: ../CONTRIBUTORS.md (relative to this template — the file lives + at the repository root, on whatever branch you're reading). Maintainers: delete this template body and describe your change normally. --> > **Heads up:** this repository accepts **issues, not pull requests** from -> external contributors. Please read [`CONTRIBUTORS.md`](../blob/main/CONTRIBUTORS.md) +> external contributors. Please read [`CONTRIBUTORS.md`](../CONTRIBUTORS.md) > before continuing. If you're an external contributor, open an issue (and > share the prompt you used, if you've already built the change) rather than > this PR. From ff9954730957b9c91df17cb62a3ec2612459d73b Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:03:05 -0400 Subject: [PATCH 010/941] docs: restore SECURITY.md on v2/main, so the next tree swap can't drop it (#1867) Closes #1864. SECURITY.md was restored to main in #1843 but never added to v2/main. Because v2/main is the develop branch merged into main at milestones, and the two share no common git ancestor, the next milestone merge would silently remove the security policy from the default branch a second time. Copied byte-for-byte from main (sha256 bbc8aaa5a33d481f902c1b7ac260a4c9c0d41dac5bb2916607222dd205dda924, 2633 bytes). No content changes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- SECURITY.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..3e26f216 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,53 @@ +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Supported Versions + +| Version | Package | Support | +| ------- | ---------------------------------------- | ------------------------------------------------------- | +| **2.x** | `@modelcontextprotocol/inspector` | ✅ Actively supported | +| 1.x | `@modelcontextprotocol/inspector` | ⚠️ **Security fixes only**, published under `v1-latest` | +| 1.x | `@modelcontextprotocol/inspector-client` | ⚠️ Security fixes only | +| 1.x | `@modelcontextprotocol/inspector-server` | ⚠️ Security fixes only | +| 1.x | `@modelcontextprotocol/inspector-cli` | ⚠️ Security fixes only | +| < 1.0.0 | any | ❌ Unsupported | + +v2 ships as a **single** package — `@modelcontextprotocol/inspector`. The three +`inspector-client` / `inspector-server` / `inspector-cli` sub-packages exist only +on the v1 line and are deprecated. + +v1 development happens on the `v1/main` branch and is limited to security fixes. +Those releases are published under the **`v1-latest`** dist-tag so they never +displace the current v2 release: + +```bash +npm i @modelcontextprotocol/inspector@v1-latest +``` + +Everything else — features, bug fixes, improvements — lands in v2 only. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. Private vulnerability reporting is enabled, so this is the +fastest route to a maintainer. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +Note that this repository does not accept pull requests from outside contributors +(see [CONTRIBUTORS.md](./CONTRIBUTORS.md)) — **this does not apply to security +reports**, which should always go through the advisory process above rather than +any public channel. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Whether it affects v2, v1, or both +- Any suggested fixes (optional) From ee707db52dd95801566864d70493299c20360258 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:03:14 -0400 Subject: [PATCH 011/941] ci: restore the Claude Code workflow and .mcp.json, lost in the v2 tree swap (#1869) * ci: restore the Claude Code workflow, lost in the v2 tree swap Closes #1850. Restored byte-for-byte from ac3c1a12 (sha256 1c44f2d9d51c936adb40c9acefd4b44628fe7810360e2f8196034347f9d2602d, 3592 bytes). No content changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * ci: restore .mcp.json, which claude.yml's --mcp-config points at The restored workflow runs with `--mcp-config .mcp.json` and `--allowedTools "Bash,mcp__mcp-docs"`, so without this file the mcp-docs server never registers and those tools do not resolve. Lost in the same tree swap: .mcp.json survives only on v1/main, and is absent from both main and v2/main. Restored byte-for-byte from 47f92841 (sha256 b0f0b8ea0d6c6e4bd9d19270db8c4ed13a951c6a7d8fa9a7b76d0d12ddd2491f, 123 bytes). No content changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/claude.yml | 93 ++++++++++++++++++++++++++++++++++++ .mcp.json | 8 ++++ 2 files changed, 101 insertions(+) create mode 100644 .github/workflows/claude.yml create mode 100644 .mcp.json diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..8aa4ba71 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,93 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + ( + (github.event_name == 'issue_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && + contains(github.event.comment.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && + contains(github.event.review.body, '@claude') && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && + (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && + contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association)) + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read + steps: + - name: Get PR details + if: | + (github.event_name == 'issue_comment' && github.event.issue.pull_request) || + github.event_name == 'pull_request_review_comment' || + github.event_name == 'pull_request_review' + id: pr + uses: actions/github-script@v8 + with: + script: | + let prNumber; + if (context.eventName === 'issue_comment') { + prNumber = context.issue.number; + } else { + prNumber = context.payload.pull_request.number; + } + + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + core.setOutput('sha', pr.data.head.sha); + core.setOutput('repo', pr.data.head.repo.full_name); + + - name: Checkout PR branch + if: steps.pr.outcome == 'success' + uses: actions/checkout@v6 + with: + ref: ${{ steps.pr.outputs.sha }} + repository: ${{ steps.pr.outputs.repo }} + fetch-depth: 0 + + - name: Checkout repository + if: steps.pr.outcome != 'success' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # Allow Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Trigger when assigned to an issue + assignee_trigger: "claude" + + claude_args: | + --mcp-config .mcp.json + --allowedTools "Bash,mcp__mcp-docs" + --append-system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block. When working on MCP-related code or reviewing MCP-related changes, use the mcp-docs MCP server to look up the latest protocol documentation. For schema details, reference https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema which contains versioned schemas in JSON (schema.json) and TypeScript (schema.ts) formats." diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..5f68642a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mcp-docs": { + "type": "http", + "url": "https://modelcontextprotocol.io/mcp" + } + } +} From 13e981e9dd29c677a37623753faa470763807b53 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:03:24 -0400 Subject: [PATCH 012/941] chore: restore repo files dropped by the v2 tree swap (#1871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: restore repo files dropped by the v2 tree swap A sweep of the pre-swap tree (ac3c1a12) against v2/main turned up 14 files the tree swap dropped and that were never restored to either branch. Most are correctly gone (v1 CI workflows, v1 sample-config, husky hook, two 0-byte files, v1-specific prettier config). These four are worth having: - .github/dependabot.yml — rewritten, not restored. v1's covered only github-actions at /; this covers github-actions plus the six npm manifests v2 actually has (root + four clients), monthly rather than weekly, grouped to one PR per ecosystem/directory, targeting v2/main, and labeled `v2` so the version-label rule holds for bot PRs too. - .github/ISSUE_TEMPLATE/bug_report.md — rewritten. v1's asked for an "Inspector Version e.g. 0.16.5" and had no client or version field. Under the issues-only policy this template is the primary intake, so it now asks the routing questions first (v1 vs v2, which client) and points at the prompt-not-a-diff path. - .gitattributes — restored byte-for-byte (package-lock.json linguist-generated=true). More useful in v2 than v1: there are now six lockfiles, and they dominate dependency-bump diffs. - CODE_OF_CONDUCT.md — restored byte-for-byte, so GitHub surfaces it as a community standard. Left unformatted deliberately: it is verbatim upstream Contributor Covenant text, v1 listed it in .prettierignore for that reason, and no v2 format glob reaches root markdown. CONTRIBUTING.md is deliberately not restored here — it is handled by renaming CONTRIBUTORS.md to that name, which also lights up GitHub's contributing banner without leaving two files to keep in sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs(dependabot): state the documented target-branch/security-update rule Copilot review: the comment read as if `target-branch` had no bearing on security updates. Per GitHub's Dependabot options reference, an entry whose `target-branch` names a non-default branch is not applied to security updates at all — so the schedule/labels/groups here shape version updates only, and security PRs continue to be raised against the default branch. Comment-only; the configured settings are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: correct the branch description and the labeling ask Two suppressed Copilot comments, both correct: - dependabot.yml called `main` "release-only". It isn't — README.md and AGENTS.md both describe it as the legacy v1 implementation taking security and bug fixes only. Reworded to match, keeping the point that matters (Dependabot reads config from the default branch). - bug_report.md asked reporters to label the issue `v2`/`v1`, which most external reporters have no permission to do. It now asks only for the version checkbox and says a maintainer labels it at triage. Comment/prose only; no configured Dependabot setting changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs(dependabot): describe main as release-only, not "legacy v1" Reverts the branch description introduced in c7fbf350. That change was made to match README.md:17 and AGENTS.md — but those are precisely the stale sentences PR #1866 exists to correct. `main` has not been the legacy v1 implementation since the go-live tree swap; it is the release branch holding the latest released v2, and the deprecated v1 line lives on `v1/main`. So the comment now describes the real model: v2/main is the develop branch, main is release-only and receives milestone merges, v1/main is maintenance and takes security fixes only. The rest of c7fbf350 stands — the bug_report.md change (asking reporters for the version checkbox rather than to apply a label they lack permission to set) was a genuine catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .gitattributes | 1 + .github/ISSUE_TEMPLATE/bug_report.md | 73 +++++++++++++++ .github/dependabot.yml | 93 +++++++++++++++++++ CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/dependabot.yml create mode 100644 CODE_OF_CONDUCT.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..cf968721 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +package-lock.json linguist-generated=true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..a038f066 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,73 @@ +--- +name: Bug report +about: Report something broken in the Inspector +title: "" +labels: "" +assignees: "" +--- + +<!-- +Issues are how work reaches the Inspector — we accept issues, not pull +requests. See CONTRIBUTORS.md for the full policy. + +Just tick the version below — a maintainer applies the `v1` / `v2` label +during triage (most reporters can't set labels themselves). When in doubt, +it's v2. +--> + +**Which version?** + +- [ ] v2 — current, published as `@modelcontextprotocol/inspector@latest` +- [ ] v1 — deprecated, `@v1-latest` (security and bug fixes only) + +**Inspector version** + +<!-- e.g. 2.0.0 — the version you actually ran, not "latest" --> + +**Which client?** + +- [ ] Web +- [ ] CLI +- [ ] TUI +- [ ] All / shared core + +**What happened** + +<!-- The actual behavior you observed. --> + +**What you expected instead** + +**Steps to reproduce** + +1. +2. +3. + +<!-- +If it depends on a particular MCP server, say which one and how it's +configured — transport (stdio / streamable HTTP / SSE), protocol era +(legacy / modern), and whether OAuth is involved. +--> + +**Environment** + +- OS: +- Node version: +- Browser (web client only): +- MCP server under inspection: + +**Logs, errors, or screenshots** + +<!-- +Console output, the Inspector's Protocol or Network tab, or a screenshot. +Redact tokens and secrets first. +--> + +**Already prototyped a fix?** + +<!-- +Please don't attach a diff or open a PR. Share the *prompt* you used to +produce the change, plus what you verified — we'll reproduce it through our +own workflow so it lands with the right conventions, tests, and coverage. +See CONTRIBUTORS.md → "If you've already fixed it locally". +--> diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6888530a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,93 @@ +version: 2 + +# Version updates target `v2/main`, the develop branch where all v2 work lands, +# not the default branch — `main` is release-only, holding the latest released +# v2 and receiving milestone merges from `v2/main`. (The deprecated v1 line +# lives on `v1/main` and takes security fixes only.) +# +# Two things to know about this file: +# 1. Dependabot reads it from the DEFAULT branch (`main`). Changes here are +# inert until the next milestone merge carries them there. +# 2. `target-branch` scopes VERSION updates. Dependabot SECURITY updates are +# enabled in repo settings, not here, and are raised against the default +# branch — they kept working while this file was missing entirely +# (see #1833, #1840). Per GitHub's Dependabot options reference, an entry +# whose `target-branch` names a non-default branch is NOT applied to +# security updates, so the schedule/labels/groups below shape version +# updates only. Re-confirm security PRs still appear after this lands. +# +# v2 is not an npm workspace: the root and each client under `clients/*` carry +# their own package.json + lockfile, so each needs its own entry. + +updates: + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + root-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/web" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + web-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/cli" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + cli-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/tui" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + tui-dependencies: + patterns: + - "*" + + - package-ecosystem: "npm" + directory: "/clients/launcher" + target-branch: "v2/main" + schedule: + interval: "monthly" + labels: + - "v2" + groups: + launcher-dependencies: + patterns: + - "*" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..05c32c60 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +mcp-coc@anthropic.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. From 694d013f2688f202cc2612e66f3eca35de71bee6 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Fri, 31 Jul 2026 23:03:34 -0400 Subject: [PATCH 013/941] chore(deps): bring main's rc-series bumps to v2/main (hono 2.x, vite 8.1.5) (#1881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.0.0-rc series shipped these dependency bumps to `main` only, so `v2/main` was developing against older versions and would have met them for the first time at the milestone merge. - `@hono/node-server` ^1.19.14 -> ^2.0.12 (root, clients/web) — a major. v2.0.0's only breaking changes are dropping Node 18 (the repo already requires >=22.19.0) and removing the `@hono/node-server/vercel` adapter (unused here). The used surface — `serve()`, `serveStatic`, `ServerType` — is unchanged, so no source edits were needed. - `vite` ^8.0.0 -> ^8.1.5 (root, clients/web) and ^8.0.16 -> ^8.1.5 (clients/tui) — the security bumps from #1841. `npm install` at the root (postinstall cascades into every client) regenerated the three touched lockfiles. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- clients/tui/package-lock.json | 348 +++++++++++++++-------------- clients/tui/package.json | 2 +- clients/web/package-lock.json | 402 ++++++++++++++++++---------------- clients/web/package.json | 4 +- package-lock.json | 371 +++++++++++++++++-------------- package.json | 4 +- 6 files changed, 620 insertions(+), 511 deletions(-) diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index e805f79c..90e9ff32 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -39,7 +39,7 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16", + "vite": "^8.1.5", "vite-node": "^6.0.0", "vitest": "^4.1.0" } @@ -335,21 +335,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "dev": true, "license": "MIT", "optional": true, @@ -358,9 +358,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -1357,28 +1357,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1392,9 +1395,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -1409,9 +1412,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -1426,9 +1429,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -1443,9 +1446,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -1460,9 +1463,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -1477,13 +1480,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1494,13 +1500,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1511,13 +1520,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1528,13 +1540,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1545,13 +1560,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1562,13 +1580,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1579,9 +1600,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -1596,28 +1617,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -1632,9 +1650,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -2013,9 +2031,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4132,9 +4150,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4148,23 +4166,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -4183,9 +4201,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -4204,9 +4222,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -4225,9 +4243,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4246,9 +4264,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4267,13 +4285,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4288,13 +4309,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4309,13 +4333,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4330,13 +4357,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4351,9 +4381,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4372,9 +4402,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4548,9 +4578,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4755,9 +4785,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -4836,9 +4866,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -4856,7 +4886,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5048,13 +5078,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5064,21 +5094,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/rollup": { @@ -6210,16 +6240,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -6236,7 +6266,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/clients/tui/package.json b/clients/tui/package.json index 7799b1f7..77a018f5 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -58,7 +58,7 @@ "tsx": "^4.21.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.16", + "vite": "^8.1.5", "vite-node": "^6.0.0", "vitest": "^4.1.0" } diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 18e0d71c..999fcbe5 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -11,7 +11,7 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", - "@hono/node-server": "^1.19.14", + "@hono/node-server": "^2.0.12", "@mantine/core": "^8.3.17", "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", @@ -74,7 +74,7 @@ "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0", + "vite": "^8.1.5", "vitest": "^4.1.0", "yaml": "^2.9.0" } @@ -417,21 +417,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "dev": true, "license": "MIT", "optional": true, @@ -440,9 +440,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -1247,12 +1247,12 @@ "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": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -1540,6 +1540,19 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -1800,20 +1813,25 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@neoconfetti/react": { @@ -1823,20 +1841,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1857,9 +1865,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -1874,9 +1882,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -1891,9 +1899,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -1908,9 +1916,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -1925,9 +1933,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -1942,13 +1950,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1959,13 +1970,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1976,13 +1990,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1993,13 +2010,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2010,13 +2030,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2027,13 +2050,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2044,9 +2070,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -2061,26 +2087,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -2095,9 +2120,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -2842,9 +2867,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -6214,9 +6239,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -6230,23 +6255,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -6265,9 +6290,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -6286,9 +6311,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -6307,9 +6332,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -6328,9 +6353,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -6349,13 +6374,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6370,13 +6398,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6391,13 +6422,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6412,13 +6446,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6433,9 +6470,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -6454,9 +6491,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -7636,9 +7673,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -7972,9 +8009,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -8110,9 +8147,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -8130,7 +8167,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8783,14 +8820,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -8799,27 +8836,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -9464,14 +9501,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -10070,18 +10107,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -10097,8 +10133,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/clients/web/package.json b/clients/web/package.json index 9be68f0a..81a8d4af 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -36,7 +36,7 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.14.0", - "@hono/node-server": "^1.19.14", + "@hono/node-server": "^2.0.12", "@mantine/core": "^8.3.17", "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", @@ -96,7 +96,7 @@ "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vite": "^8.0.0", + "vite": "^8.1.5", "vitest": "^4.1.0", "yaml": "^2.9.0" } diff --git a/package-lock.json b/package-lock.json index 903dd105..96b4f42e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.14", + "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", @@ -30,7 +30,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "undici": "^8.5.0", - "vite": "^8.0.0", + "vite": "^8.1.5", "yaml": "^2.9.0", "zod": "^4.3.6" }, @@ -63,20 +63,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "license": "MIT", "optional": true, "dependencies": { @@ -84,9 +84,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "license": "MIT", "optional": true, "dependencies": { @@ -275,12 +275,12 @@ } }, "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": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -452,6 +452,19 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -712,27 +725,30 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -745,9 +761,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -761,9 +777,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -777,9 +793,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -793,9 +809,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -809,9 +825,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -825,12 +841,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -841,12 +860,15 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -857,12 +879,15 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -873,12 +898,15 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -889,12 +917,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -905,12 +936,15 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -921,9 +955,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -937,27 +971,24 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -971,9 +1002,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -999,9 +1030,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -3053,9 +3084,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -3068,23 +3099,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3102,9 +3133,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3122,9 +3153,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3142,9 +3173,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3162,9 +3193,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3182,12 +3213,15 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3202,12 +3236,15 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3222,12 +3259,15 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3242,12 +3282,15 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3262,9 +3305,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3282,9 +3325,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -3413,9 +3456,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -3653,9 +3696,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -3711,9 +3754,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -3730,7 +3773,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3933,12 +3976,12 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3948,21 +3991,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/router": { @@ -4529,15 +4572,15 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -4554,7 +4597,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index 45ed8a9e..2adc0d5f 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "postinstall": "node scripts/install-clients.mjs" }, "dependencies": { - "@hono/node-server": "^1.19.14", + "@hono/node-server": "^2.0.12", "@modelcontextprotocol/client": "2.0.0-beta.5", "@modelcontextprotocol/core": "2.0.0-beta.5", "@modelcontextprotocol/ext-apps": "^1.7.4", @@ -94,7 +94,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "undici": "^8.5.0", - "vite": "^8.0.0", + "vite": "^8.1.5", "yaml": "^2.9.0", "zod": "^4.3.6" }, From 8801d0f8bc25829759e8cbb76550726ec89678b4 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 19:50:00 -0400 Subject: [PATCH 014/941] fix(scripts): await the pack:verify web child's exit before removing its work dir (#1895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scripts): await the pack:verify web child's exit before removing its work dir `pack-and-verify.mjs` had the same kill-then-remove shape that made `smoke:tui` flake with ENOTEMPTY in #1801: `verifyWeb()`'s `finally { stop() }` only *delivered* SIGTERM, then the main flow `rmSync`'d the work dir immediately, and `fail()` did the same back to back. Applies the #1814 treatment, factored into a shared `scripts/lib/child-cleanup.mjs` so the two scripts can't drift: - `stopChild()` — SIGTERM, await the child's `exit`/`close`, escalate to SIGKILL after a grace period, and ultimately proceed with a warning so a wedged child can never hang the script. `verifyWeb()`'s `stop()` now goes through it and is awaited, so the child is gone before the caller removes `work`. - `removeSafe()` — `rmSync` that warns instead of throwing. Used on both success-path removals (a leftover temp dir must never fail a run that passed) and in `fail()`, where an ENOTEMPTY could otherwise bury the real diagnostic under an rmSync stack and skip the "tarball retained at ..." hint. `fail()` stays synchronous — it is called from sync contexts and ends in `process.exit()` — so it best-effort signals the child; `removeSafe()` is what makes that safe. `smoke-tui.mjs` adopts `removeSafe()` for its `cleanup()`. Its `done()` keeps its bespoke wait (documented inline): that one must keep waiting for `close` *after* `exit` on a shorter re-armed deadline so the quoted output is complete, which `stopChild()`'s first-of-exit/close contract deliberately does not do. Adds `scripts/lib/child-cleanup.test.mjs` (10 cases, `node --test` via `npm run test:scripts`) covering the SIGTERM/SIGKILL escalation, the `close`-only spawn-failure path, the never-hang give-up, late events after giving up, and that `removeSafe` warns rather than throws. `npm run ci` passes, and `npm run pack:verify` (not in the gate — needs network) was run end to end and passes with no cleanup warnings. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): normalize stopChild's graceMs and correct removeSafe's JSDoc Copilot review round 1 on #1895. - `stopChild()` trusted `graceMs` as given, but call sites build it as `Number(process.env.X ?? 5000)` — a typo yields `NaN`, which `setTimeout` treats as `0`. That would fire SIGTERM and SIGKILL in the same tick and print "did not exit within NaNms", collapsing the whole escalation silently. New exported `normalizeGraceMs()` falls back to `DEFAULT_EXIT_GRACE_MS` for anything non-finite or non-positive, with two tests: the table of bad values, and an end-to-end one asserting a `NaN` grace period still leaves only SIGTERM sent a turn later. - `removeSafe()`'s `@returns` said "true if the path was removed", which is inaccurate for a missing path (`force: true` makes it a no-op success). Reworded to say what it actually reports. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- scripts/lib/child-cleanup.mjs | 146 ++++++++++++++++++++++++ scripts/lib/child-cleanup.test.mjs | 174 +++++++++++++++++++++++++++++ scripts/pack-and-verify.mjs | 46 ++++++-- scripts/smoke-tui.mjs | 27 ++--- 4 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 scripts/lib/child-cleanup.mjs create mode 100644 scripts/lib/child-cleanup.test.mjs diff --git a/scripts/lib/child-cleanup.mjs b/scripts/lib/child-cleanup.mjs new file mode 100644 index 00000000..19899205 --- /dev/null +++ b/scripts/lib/child-cleanup.mjs @@ -0,0 +1,146 @@ +/** + * Shared teardown helpers for scripts that spawn a child process into a temp + * work dir and then delete that dir (#1801 / #1814, generalized in #1826). + * + * The bug class both helpers exist for: a script signals its child and then + * *synchronously* removes the work dir. `child.kill()` only delivers the + * signal — it does not wait — so the removal can race the child's shutdown and + * fail with ENOTEMPTY when a file lands after a directory has been read but + * before it is removed. That turned a passing smoke red in #1801. + * + * The two halves are deliberately complementary, and both are needed: + * + * - `stopChild()` removes the race on the normal path by awaiting the child's + * exit (SIGTERM → grace → SIGKILL → proceed anyway with a warning); + * - `removeSafe()` makes the residual case harmless — a path that still can't + * be removed warns and leaks into tmpdir (which the OS reclaims) instead of + * throwing an rmSync stack over the script's own result message. + * + * Both take an injectable `warn` so they can be unit-tested without capturing + * console output (`npm run test:scripts`). + */ + +import { rmSync } from "node:fs"; + +/** Default wait after SIGTERM before escalating to SIGKILL. */ +export const DEFAULT_EXIT_GRACE_MS = 5000; + +/** + * Coerce a caller-supplied grace period to a usable one. + * + * Call sites read this from the environment (`Number(process.env.X ?? 5000)`), + * so a typo yields `NaN` — which `setTimeout` treats as `0`, silently turning + * the whole SIGTERM→SIGKILL escalation into an immediate double-kill and + * printing "did not exit within NaNms". Fall back to the default instead. + * + * @param {unknown} graceMs + */ +export function normalizeGraceMs(graceMs) { + return typeof graceMs === "number" && Number.isFinite(graceMs) && graceMs > 0 + ? graceMs + : DEFAULT_EXIT_GRACE_MS; +} + +/** + * True once the child process is known to be gone (normal exit or signalled). + * Both fields stay `null` while it is alive, and `killed` is NOT a substitute — + * it only means a signal was delivered, not that the process is dead yet. + * + * @param {{ exitCode: number | null, signalCode: string | null }} child + */ +export function hasExited(child) { + return child.exitCode !== null || child.signalCode !== null; +} + +/** + * Terminate `child` and wait for it to actually be gone, so a caller can then + * delete the child's work dir without racing its shutdown. + * + * Escalation: SIGTERM → (after `graceMs`) SIGKILL → (after `graceMs` again) + * give up and resolve anyway. Giving up is deliberate: a teardown helper must + * never hang a script, and `removeSafe()` below downgrades the consequence of + * proceeding early to a warning. + * + * Waits on `exit` OR `close`, whichever comes first. `exit` alone is wrong for a + * *spawn failure* (which emits `error` + `close` and never `exit`, so the wait + * would burn both timeouts before proceeding); `close` alone can outlive the + * child when a descendant inherited its stdio pipes. + * + * @param {import("node:child_process").ChildProcess} child + * @param {object} [opts] + * @param {string} [opts.label] prefix for warnings, e.g. "pack:verify" + * @param {string} [opts.what] what the child is, for warnings + * @param {number} [opts.graceMs] non-finite/non-positive falls back to the default + * @param {(msg: string) => void} [opts.warn] + * @returns {Promise<"already-exited" | "exited" | "gave-up">} + */ +export function stopChild( + child, + { + label = "cleanup", + what = "child process", + graceMs: requestedGraceMs = DEFAULT_EXIT_GRACE_MS, + warn = console.warn, + } = {}, +) { + if (hasExited(child)) return Promise.resolve("already-exited"); + const graceMs = normalizeGraceMs(requestedGraceMs); + + return new Promise((resolve) => { + let settled = false; + const finish = (outcome) => { + if (settled) return; + settled = true; + clearTimeout(forceKill); + clearTimeout(deadline); + resolve(outcome); + }; + + const forceKill = setTimeout(() => { + warn( + `${label} — ${what} did not exit within ${graceMs}ms of SIGTERM; sending SIGKILL`, + ); + child.kill("SIGKILL"); + }, graceMs); + + const deadline = setTimeout(() => { + warn( + `${label} — ${what} did not exit within ${graceMs * 2}ms even after SIGKILL; continuing anyway`, + ); + finish("gave-up"); + }, graceMs * 2); + + child.once("exit", () => finish("exited")); + child.once("close", () => finish("exited")); + + child.kill("SIGTERM"); + }); +} + +/** + * `rmSync(path, { recursive, force })` that warns instead of throwing. + * + * Never let a leftover temp path change a script's verdict: on the success path + * a throw here would fail a run that actually passed, and on a failure path it + * would bury the real diagnostic under an rmSync stack. The OS reclaims tmpdir. + * + * @param {string} path + * @param {object} [opts] + * @param {string} [opts.label] + * @param {(msg: string) => void} [opts.warn] + * @returns {boolean} true if the path is gone — which includes a path that was + * never there, since `force: true` makes a missing path a no-op success. + * False means removal threw and a warning was emitted. + */ +export function removeSafe( + path, + { label = "cleanup", warn = console.warn } = {}, +) { + try { + rmSync(path, { recursive: true, force: true }); + return true; + } catch (err) { + warn(`${label} — could not remove ${path}: ${err.message}`); + return false; + } +} diff --git a/scripts/lib/child-cleanup.test.mjs b/scripts/lib/child-cleanup.test.mjs new file mode 100644 index 00000000..35cea586 --- /dev/null +++ b/scripts/lib/child-cleanup.test.mjs @@ -0,0 +1,174 @@ +// Tests for the shared child-teardown helpers (#1826). Each case pins one rule +// the kill-then-remove race (#1801) turned out to need. Run via +// `npm run test:scripts` (node:test; the root has no vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_EXIT_GRACE_MS, + hasExited, + normalizeGraceMs, + removeSafe, + stopChild, +} from "./child-cleanup.mjs"; + +/** + * Minimal stand-in for a ChildProcess: records the signals it was sent and lets + * a test decide when (or whether) it emits exit/close. + */ +function fakeChild({ exitCode = null, signalCode = null } = {}) { + const child = new EventEmitter(); + child.exitCode = exitCode; + child.signalCode = signalCode; + child.signals = []; + child.kill = (signal) => { + child.signals.push(signal); + return true; + }; + return child; +} + +function collectWarnings() { + const warnings = []; + return { warnings, warn: (msg) => warnings.push(msg) }; +} + +test("hasExited: only exitCode/signalCode count, not `killed`", () => { + assert.equal(hasExited(fakeChild()), false); + assert.equal(hasExited(fakeChild({ exitCode: 0 })), true); + assert.equal(hasExited(fakeChild({ exitCode: 1 })), true); + assert.equal(hasExited(fakeChild({ signalCode: "SIGTERM" })), true); + // `killed` means "a signal was delivered", NOT "the process is dead" — a + // helper that treated it as exited would reintroduce the very race. + const killed = fakeChild(); + killed.killed = true; + assert.equal(hasExited(killed), false); +}); + +test("normalizeGraceMs: a bad env-derived value can't collapse the escalation", () => { + // `Number(process.env.X ?? 5000)` is how call sites build this, so a typo + // yields NaN — which setTimeout treats as 0, firing SIGTERM and SIGKILL in + // the same tick and printing "within NaNms". + for (const bad of [NaN, 0, -1, Infinity, undefined, null, "3000"]) + assert.equal(normalizeGraceMs(bad), DEFAULT_EXIT_GRACE_MS, String(bad)); + assert.equal(normalizeGraceMs(25), 25); +}); + +test("stopChild: a NaN graceMs falls back rather than double-killing at once", async () => { + const child = fakeChild(); + const { warnings, warn } = collectWarnings(); + const stopped = stopChild(child, { graceMs: NaN, warn }); + // With NaN passed through, both timers would already have been scheduled at + // 0ms; a macrotask turn is enough to observe that. + await new Promise((r) => setImmediate(r)); + assert.deepEqual(child.signals, ["SIGTERM"]); + assert.deepEqual(warnings, []); + child.emit("exit", 0); + assert.equal(await stopped, "exited"); +}); + +test("stopChild: an already-exited child is not signalled again", async () => { + const child = fakeChild({ exitCode: 0 }); + const { warnings, warn } = collectWarnings(); + assert.equal(await stopChild(child, { warn }), "already-exited"); + assert.deepEqual(child.signals, []); + assert.deepEqual(warnings, []); +}); + +test("stopChild: SIGTERMs a live child and resolves on `exit`", async () => { + const child = fakeChild(); + const { warnings, warn } = collectWarnings(); + const stopped = stopChild(child, { graceMs: 50, warn }); + // The signal goes out synchronously, so the caller's wait is genuinely a wait + // for the child rather than a wait to signal it. + assert.deepEqual(child.signals, ["SIGTERM"]); + child.exitCode = 0; + child.emit("exit", 0); + assert.equal(await stopped, "exited"); + assert.deepEqual(warnings, []); +}); + +test("stopChild: resolves on `close` alone (spawn failure emits no `exit`)", async () => { + const child = fakeChild(); + const { warnings, warn } = collectWarnings(); + const stopped = stopChild(child, { graceMs: 50, warn }); + child.emit("close", null, "SIGTERM"); + assert.equal(await stopped, "exited"); + assert.deepEqual(warnings, []); +}); + +test("stopChild: escalates to SIGKILL after the grace period", async () => { + const child = fakeChild(); + const { warnings, warn } = collectWarnings(); + const stopped = stopChild(child, { + graceMs: 20, + label: "pack:verify", + what: "`--web` server", + warn, + }); + // Ignores SIGTERM; dies only once SIGKILLed. + child.once("kill:SIGKILL", () => child.emit("exit", null)); + const originalKill = child.kill; + child.kill = (signal) => { + originalKill(signal); + if (signal === "SIGKILL") child.emit("kill:SIGKILL"); + return true; + }; + assert.equal(await stopped, "exited"); + assert.deepEqual(child.signals, ["SIGTERM", "SIGKILL"]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /pack:verify — `--web` server did not exit/); + assert.match(warnings[0], /sending SIGKILL/); +}); + +test("stopChild: gives up (never hangs) when even SIGKILL is not observed", async () => { + const child = fakeChild(); + const { warnings, warn } = collectWarnings(); + // Emits nothing ever: the helper must still resolve, since a teardown path + // that can hang is worse than one that proceeds with a warning. + assert.equal(await stopChild(child, { graceMs: 10, warn }), "gave-up"); + assert.deepEqual(child.signals, ["SIGTERM", "SIGKILL"]); + assert.equal(warnings.length, 2); + assert.match(warnings[1], /continuing anyway/); +}); + +test("stopChild: a late exit after giving up does not re-resolve or throw", async () => { + const child = fakeChild(); + const { warn } = collectWarnings(); + assert.equal(await stopChild(child, { graceMs: 10, warn }), "gave-up"); + child.emit("exit", null); + child.emit("close", null); +}); + +test("removeSafe: removes a populated directory and reports true", () => { + const dir = mkdtempSync(join(tmpdir(), "child-cleanup-test-")); + writeFileSync(join(dir, "file.txt"), "x"); + const { warnings, warn } = collectWarnings(); + assert.equal(removeSafe(dir, { warn }), true); + assert.equal(existsSync(dir), false); + assert.deepEqual(warnings, []); +}); + +test("removeSafe: a missing path is a no-op success (force)", () => { + const { warnings, warn } = collectWarnings(); + assert.equal( + removeSafe(join(tmpdir(), "child-cleanup-does-not-exist-1826"), { warn }), + true, + ); + assert.deepEqual(warnings, []); +}); + +test("removeSafe: warns instead of throwing when removal fails", () => { + const { warnings, warn } = collectWarnings(); + // A non-string path makes rmSync throw synchronously, standing in for the + // ENOTEMPTY this helper exists to swallow. The contract under test is that + // NOTHING escapes — a leftover temp dir must never change a script's verdict. + assert.equal(removeSafe(42, { label: "pack:verify", warn }), false); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^pack:verify — could not remove 42: /); +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 8926b10c..c19bbfe4 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -39,10 +39,11 @@ */ import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { mkdtempSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { hasExited, removeSafe, stopChild } from "./lib/child-cleanup.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const testServer = join( @@ -69,13 +70,22 @@ let workDir = null; // own `finally { stop() }` is skipped when fail() calls process.exit()). let webChild = null; +const LABEL = "pack:verify"; + function fail(message) { console.error(`\npack:verify FAILED — ${message}`); - if (webChild && webChild.exitCode === null) { + // fail() is the single failure-exit point and is called from synchronous + // contexts (top-level statements, loop bodies), so it cannot await the + // child's exit the way the success path does — it best-effort signals and + // moves on. That is why removeSafe() matters most here: this path is already + // exiting 1, so an ENOTEMPTY could only bury the real diagnostic under an + // rmSync stack (and skip the "tarball retained" hint below), never turn a + // green run red. + if (webChild && !hasExited(webChild)) { webChild.kill("SIGTERM"); } if (workDir) { - rmSync(workDir, { recursive: true, force: true }); + removeSafe(workDir, { label: LABEL }); // `tarball` is initialized before `workDir` is ever set, so this is safe. console.error( `pack:verify — tarball retained for inspection at ${tarball}`, @@ -327,8 +337,13 @@ try { // Success: clean up both the work dir and the tarball. (This is reached only // on success — every failure path goes through fail() → process.exit(), which // does its own cleanup above and never returns here.) - rmSync(work, { recursive: true, force: true }); - rmSync(tarball, { force: true }); + // + // verifyWeb() has already awaited the `--web` child's exit before returning + // (#1826), so nothing of ours is still running inside `work` here. Removal is + // still routed through removeSafe(): a leftover temp dir must never fail a run + // that actually passed. + removeSafe(work, { label: LABEL }); + removeSafe(tarball, { label: LABEL }); console.log( "\npack:verify OK — published tarball installs clean and the real bin drives " + @@ -374,10 +389,21 @@ async function verifyWeb(bin, cwd) { exited = true; exitCode = code; }); - const stop = () => { - if (!exited) child.kill("SIGTERM"); - webChild = null; - }; + + // Signal the server AND wait for it to be gone before returning: the caller + // removes `work` (the installed package the child is running out of, and its + // cwd) as its very next statement, and `child.kill()` only delivers the + // signal (#1826 — the same kill-then-remove race #1801 hit in smoke:tui). + // Escalates to SIGKILL and ultimately proceeds with a warning, so a wedged + // server can never hang the verify. + const stop = () => + stopChild(child, { + label: LABEL, + what: "`--web` server", + graceMs: Number(process.env.PACK_VERIFY_EXIT_GRACE_MS ?? 5000), + }).finally(() => { + webChild = null; + }); try { let res = null; @@ -409,6 +435,6 @@ async function verifyWeb(bin, cwd) { fail("`--web` served HTML is missing the injected auth-token value"); } } finally { - stop(); + await stop(); } } diff --git a/scripts/smoke-tui.mjs b/scripts/smoke-tui.mjs index e7d4012e..d6244cd3 100644 --- a/scripts/smoke-tui.mjs +++ b/scripts/smoke-tui.mjs @@ -20,9 +20,10 @@ */ import { spawn, spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; +import { mkdtempSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { removeSafe } from "./lib/child-cleanup.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const launcher = join(repoRoot, "clients", "launcher", "build", "index.js"); @@ -126,18 +127,12 @@ const EXIT_GRACE_MS = Number(process.env.SMOKE_TUI_EXIT_GRACE_MS ?? 5000); const DRAIN_MS = 500; function cleanup() { - try { - rmSync(work, { recursive: true, force: true }); - } catch (err) { - // Never turn a passing smoke into a failure over a leftover temp dir; the - // OS reclaims tmpdir anyway. On the normal path this should not fire, since - // removal waits for the child's `close` (see done()) — but the two give-up - // branches there call finish() without it, and a process may still hold the - // dir, so this is expected rather than anomalous on those. - console.warn( - `smoke:tui — could not remove temp dir ${work}: ${err.message}`, - ); - } + // Never turn a passing smoke into a failure over a leftover temp dir; the OS + // reclaims tmpdir anyway. On the normal path a warning should not fire, since + // removal waits for the child's `close` (see done()) — but the two give-up + // branches there call finish() without it, and a process may still hold the + // dir, so it is expected rather than anomalous on those. + removeSafe(work, { label: "smoke:tui" }); } // Tail of the child's output, for quoting in a diagnostic. Slicing an Ink @@ -184,6 +179,12 @@ function done(code, message) { // never `exit`, so an `exit` wait would hang here until the give-up timer and // then blame a SIGTERM the child never received. `close` also guarantees the // stdio pipes are drained, which is what makes the thunked messages complete. + // + // This deliberately does NOT use lib/child-cleanup.mjs's `stopChild()` (which + // pack-and-verify.mjs uses against the same race): that helper resolves on the + // FIRST of exit/close, whereas this wait is entangled with the drain phase — + // it must keep waiting for `close` after `exit`, on a shorter re-armed + // deadline, so the quoted output is complete. Only `removeSafe()` is shared. const forceKill = setTimeout(() => child.kill("SIGKILL"), EXIT_GRACE_MS); // Deadline for the wait. Re-armed shorter once the child is gone, since from From 812a1154246c780648b586ad45b86260005b3f0c Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 19:50:06 -0400 Subject: [PATCH 015/941] docs: add Incoming to both boards, adopt the v2 Priority field, and document the triage rubric (#1892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Incoming board status, adopt Priority field, document triage rubric (#1891) Board #28 had no state for "filed but not yet triaged" — Todo means maintainer-approved, so parking unreviewed issues there falsely asserted sign-off. Adds an Incoming status ahead of Todo. The board's Priority single-select existed but was unused (0 of 264 cards) and undocumented, with P0/P1/P2 options matching no vocabulary in the repo. Re-optioned to Urgent/High/Medium/Low and documented alongside Status. Completes the priority rubric: fills in the score bands (12+/9-11/6-8/<=5), expands both axes into scored tables, and fixes the truncated signal-indicator line. Severity alone tops out at High by design. Also drops the "V2 Go Live" status reference — that option is not in the field's option list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: correct the Done option id and harden the deleted-column guidance (#1891) The Done Status option was deleted from board #28, blanking the Status of all 247 cards that held it. Restored from a pre-change item-list snapshot; verified all 247 are Done again and no other card was disturbed. A recreated option never regains its old id, so Done is now 259d6aab rather than 248a3910 — updated in the option table and the merge step. The hazard callout recommended the web UI as the safe path, which is what made this look safe. It is not: deleting an option blanks its cards in the UI exactly as it does via the API, with no undo. Corrects that, adds a snapshot-first rule, and documents the recovery recipe used here — including the grouping check that proves the orphaned set is exactly the deleted option's cards before re-applying. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: add Incoming to the v1 board and document its recipes (#1891) Board #11 had the same gap as #28 — no state for "filed but not yet triaged" — so a v1 security-fix issue had nowhere to sit that didn't assert maintainer approval. Adds Incoming there too. AGENTS.md carried no v1 board recipes at all, only a link, so anyone boarding a v1 issue had to discover the project and field ids by hand and was liable to reach for #28's (which are rejected, but only after the attempt). Adds a V1 board section with its ids, states that Incoming is the default status for new items on both boards, and notes that Priority is v2-only — #11 has no such field. The Incoming option was added with all four existing option ids echoed back, and a before/after item-list diff confirms all 74 cards kept their Status. Three cards on #11 carry no Status; that predates this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: distinguish the board Priority from the org-level issue field (#1891) An issue page shows two fields named "Priority" and nothing syncs them. Ours is the project field on board #28. The other is a GitHub issue field (IFSS_kgDOAdAWeg) defined at the modelcontextprotocol org and shared by every repo in it, alongside Effort/Start date/Target date. Same name, same four option names, unrelated storage — #1891 sat at Urgent in Fields and High on the board simultaneously. Adds a table naming both, states that no pass-through exists in either direction, and says to leave the org-level one alone: it is not repo-scoped, so deleting it would strip Priority from every other org repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: mark the org Priority field untrusted and cap it at a +1 signal (#1891) Both boards are private (public: false, verified), so maintainer-assigned Status and Priority are invisible to reporters — board priority is a working queue, not a published commitment. The org-level Fields -> Priority is the opposite: public on the issue page and not part of triage. A value there is a reporter's preference, not an assessment, so it is untrusted input. It now feeds the rubric as a sixth signal bonus — flat +1 for Urgent or High, identical for both — and never maps to a band. Urgent needs 12, so nothing a reporter types reaches it alone; the issue must already sit at 11 on maintainer-assessed axes. Supersedes the earlier "it is noise" framing: the signal is worth keeping, it just cannot be allowed to decide the outcome. Also corrects the band arithmetic (2-15 -> 2-16) now that a sixth bonus exists, and notes that scores recorded on 2026-08-01 cite the old /15 denominator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: drop the /15 denominator caveat now the recorded scores are restated (#1891) The 51 triage comments were rewritten from /15 to /16, so the note saying they cite the old denominator no longer describes anything. No total moved: the reporter-set signal only scores for Urgent or High, and the one scored issue carrying an org-field value (#1826) had Low, which earns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU * docs: describe the board-option hazard per field, not Status-only (#1891) Copilot review on #1892 flagged three places that still read as Status-only now that the section covers Priority too: - the version-label bullet said a v2 issue needs "a board card with a Status"; it needs a Status and a Priority (v1 needs only a Status, since board #11 has no Priority field) - the updateProjectV2Field hazard said a bad edit "orphans the Status of every card"; it orphans whichever field was edited - the recovery recipe is genuinely Status-specific (reads .status, writes the Status field id), so say so and give the two substitutions for a Priority deletion Verified `gh project item-list --format json` exposes both .status and .priority before documenting the .priority swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- AGENTS.md | 196 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 179 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f94fd60c..cdaacd0d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,16 +146,18 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status. All four are distinct steps; do all four (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. - **Label by version — every issue and every PR, no exceptions.** Each one carries **exactly one** of `v1` or `v2` at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) - - Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status. -- **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. + Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). +- **Prioritize every new issue.** Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. +- **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. + - **New and untriaged → `Incoming`.** This is the **default status for a new item on either board.** An issue nobody has evaluated yet belongs in **Incoming**, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. + - **Priority is v2-only.** Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else. - **Every new issue gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. Get the current milestone with: ```sh @@ -165,16 +167,91 @@ All work should be driven by items on the project board. ``` Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by *when the work ships*, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. -- When work begins, create a feature branch and set the item's Status to **In Progress** (or **V2 Go Live** for a card in the go-live phases, #1804). +- When work begins, create a feature branch and set the item's Status to **In Progress**. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `248a3910`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. + - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `259d6aab`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. +## Setting issue priority + +Every issue gets a **Priority on its board card**, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later. + +> ⚠️ **There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under _Projects → Inspector V2_.** +> +> | Where it appears | What it is | Ours? | +> | --- | --- | --- | +> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | +> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | +> +> They look identical — same name, same four option names — but **nothing syncs them.** Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing `Urgent` in Fields and `High` on the board). There is no pass-through, in either direction. +> +> **Never delete the org-level field.** It belongs to the whole org, so removing it would strip Priority from every other `modelcontextprotocol` repo. +> +> **Don't set it either — but do read it.** A value there is a *reporter's* opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. + +**Axis 1 — Severity / impact (1–5).** How bad is it when it happens? + +| Score | Means | +| --- | --- | +| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | +| 2 | Minor friction with an easy workaround. | +| 3 | A real feature is broken or missing; the workaround is annoying or partial. | +| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | +| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | + +**Axis 2 — Urgency / staleness (1–5).** How time-sensitive or neglected is it? + +| Score | Means | +| --- | --- | +| 1 | No time pressure; nothing waits on it. | +| 2 | Wanted eventually. | +| 3 | Wanted this milestone, or has sat >90 days with no activity. | +| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | +| 5 | Blocking a release, or actively hurting users on a published version right now. | + +**Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: + +- Carries a `bug` or security-related label +- Linked to a milestone +- High engagement (many comments or reactions) +- Assigned to someone +- A sub-issue of a larger epic +- The reporter set `Fields → Priority` to **Urgent or High** — **+1, flat, whichever of the two they picked.** It does not map to a band, and `Urgent` earns exactly what `High` earns. See below. + +**Bands.** Axes give 2–10 and there are six bonuses, so the total runs 2–16. + +| Total | Priority | Meaning | +| --- | --- | --- | +| 12+ | **Urgent** | Drop what you're doing. | +| 9–11 | **High** | Next up after current work. | +| 6–8 | **Medium** | Scheduled normally. | +| ≤5 | **Low** | Nice to have; may sit. | + +Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something *else* also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. + +Set the resulting level on the board card with the Priority recipe in the [V2 board (#28) `gh` recipes](#v2-board-28-gh-recipes) below. + +### Trust boundary: who can set what + +**The boards are private** (`public: false`, both #28 and #11 — verified 2026-08-01). The Status and Priority a maintainer assigns are visible only to people with project access: a reporter cannot see them, cannot set them, and will never learn how their issue was scored. Board priority is a maintainers' working queue, not a published commitment. + +The org-level `Fields → Priority` is the opposite. It renders on the public issue page and is **not** part of maintainer triage, so any value there is **untrusted** — we didn't put it there, and it carries a preference rather than an assessment. + +That asymmetry is the whole reason the reporter's value earns a flat +1 and nothing more: + +- **It counts for something.** Someone flagging their own issue is real information about how much it hurts them. Discarding it throws away a signal we'd otherwise have to infer. +- **It cannot decide an outcome.** The bonus is capped, identical for `Urgent` and `High`, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. +- **Never map the value across.** A reporter selecting `Urgent` does **not** make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact. + +Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on *who assessed the issue* rather than on who was technically able to click. + +**Assess board Priority at boarding time**, from the issue as it stands. The reporter's value is one input among several, weighted as above. + ## Repository & Project Boards - **Repo**: https://github.com/modelcontextprotocol/inspector.git @@ -191,11 +268,14 @@ All work should be driven by items on the project board. - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) + **Both boards start new items in `Incoming`.** A card only leaves Incoming when a maintainer has looked at it and approved the work — that is what Todo means on either board. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). + #### V2 board (#28) `gh` recipes -The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and Status field id are stable. **The Status *option* ids are NOT stable — they are regenerated whenever the Status field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The *option* ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: ```sh +# Swap "Status" for "Priority" to fetch the other field's options. gh project field-list 28 --owner modelcontextprotocol --format json \ | jq '.fields[] | select(.name=="Status") | .options' ``` @@ -204,23 +284,71 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | --- | --- | | Project node ID | `PVT_kwDOCt2Azc4BJVxt` | | Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | +| Priority field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` | -Status option IDs (`--single-select-option-id`) — **last verified 2026-07-27**. +Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. | Status | Option ID | | --- | --- | +| Incoming | `721a3d4c` | | Todo | `fbdaf21e` | | In Progress | `195df262` | | In Review | `159c8a02` | -| Done | `248a3910` | +| Done | `259d6aab` | -Use **Todo** for approved-but-not-started work, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. +Use **Incoming** for newly filed, untriaged work, **Todo** once a maintainer has approved it and it's ready to pick up, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. -> ⚠️ **Never add, rename, or remove a board column (Status option) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans the Status of every card on the board** (all items go blank) *and* invalidates every option id in the table above. This has happened once (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: -> 1. **Add/rename/remove a column in the GitHub web UI** (Project #28 → Status field settings). This preserves ids of untouched options and never orphans cards. -> 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. Verify afterward that no card lost its Status. +Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. + +| Priority | Option ID | Rubric total | +| --- | --- | --- | +| Urgent | `79628723` | 12+ | +| High | `0a877460` | 9–11 | +| Medium | `da944a9c` | 6–8 | +| Low | `d67ac7ce` | ≤5 | + +> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) *and* invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: +> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on *other* options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). +> 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. `ProjectV2SingleSelectFieldOptionInput.id` is an optional `String`, so a mixed list works: echo the `id` for every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshot `gh project item-list … --format json` before and after and diff, don't just spot-check. +> +> Both the `Incoming` Status option and the Urgent/High/Medium/Low `Priority` options were added this way (#1891), with the before/after diff confirming all 264 cards kept their Status. > > `gh project item-add` and `gh project item-edit` are always safe — they set a card's value and never touch the field schema. When option ids change for any reason, **re-verify and update the table above** (and the references in the recipes below and the merge step above). +> +> ### Always snapshot before touching a field's options +> +> One command, and it is the difference between a five-minute restore and reconstructing statuses by inference: +> +> ```sh +> gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-snapshot.json +> ``` +> +> ### Recovering from a deleted option +> +> This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the `Done` column, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical. +> +> **The recipe below is written for a deleted *Status* option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. +> +> ```sh +> # 1. Which cards lost their value, and what did they hold? +> gh project item-list 28 --owner modelcontextprotocol --format json --limit 600 > board-broken.json +> jq -r '[.items[]|select(.status==null)|.id]' board-broken.json > lost-ids.json +> jq -r --slurpfile L lost-ids.json '($L[0]) as $lost +> | [.items[] | select(.id as $i | $lost|index($i)) | .status // "(none)"] +> | group_by(.) | map({s:.[0],c:length}) | .[] | "was \(.s): \(.c)"' board-snapshot.json +> +> # 2. Recreate the option, echoing every surviving option's id (see above). +> # NOTE: the recreated option gets a NEW id — the deleted one never comes back. +> +> # 3. Re-apply it to the orphaned cards. +> for id in $(jq -r '.[]' lost-ids.json); do +> gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$id" \ +> --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id <NEW_OPTION_ID> +> sleep 0.4 +> done +> ``` +> +> Step 1's grouping is the safety check: confirm the orphaned set is exactly the cards that held the deleted option, so you don't overwrite a card someone legitimately moved in the meantime. And because the recreated option carries a **new id**, the table above and every reference to it must be updated in the same change — `grep` the old id across the repo. The `Done` id has been `248a3910` and is now `259d6aab` for exactly this reason. ```sh # 1. Add an issue to the board — prints the item id (PVTI_…); capture it. @@ -234,23 +362,57 @@ gh project item-edit \ --single-select-option-id 195df262 ``` -The one-liner that does both, capturing the item id (use the option id for the status you want): +The full one-liner for a **new** issue — add it, then set Status and Priority (both are required; here Incoming + Medium): ```sh ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') -gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 195df262 +# Status → Incoming +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c +# Priority → Medium +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c ``` -For an issue **already on the board** (moving an existing card, e.g. to **In Review** when its PR opens), look its item id up by issue number instead of re-adding it. Keep `--limit` above the board's item count (~200 as of 2026-07-27) — past it `item-list` truncates silently, `select` matches nothing, and `item-edit --id ""` fails with an opaque node-resolution error rather than saying the limit was too low: +Each `item-edit` sets **one** field, so setting both takes two calls — there is no combined form. + +For an issue **already on the board** (moving an existing card, e.g. to **In Review** when its PR opens, or re-scoring its Priority), look its item id up by issue number instead of re-adding it. Keep `--limit` above the board's item count (~265 as of 2026-08-01) — past it `item-list` truncates silently, `select` matches nothing, and `item-edit --id ""` fails with an opaque node-resolution error rather than saying the limit was too low: ```sh -# --limit must stay above the board's item count (~200 today) — past it the +# --limit must stay above the board's item count (~265 today) — past it the # list truncates silently and item-edit fails with an opaque node-resolution error. ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ --jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id') gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 159c8a02 ``` +#### V1 board (#11) `gh` recipes + +The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and it starts in **Incoming** like a v2 one. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. + +| Thing | ID | +| --- | --- | +| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | +| Status field ID | `PVTSSF_lADOCt2Azc4BA5szzgzkS-g` | + +Status option IDs — **last verified 2026-08-01**. + +| Status | Option ID | +| --- | --- | +| Incoming | `831820cf` | +| Todo | `f75ad846` | +| In Progress | `47fc9ee4` | +| In Review | `0439b2bf` | +| Done | `98236657` | + +There is **no Priority field on this board** — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist. + +```sh +# Add a v1 issue to board #11 and put it in Incoming. +ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') +gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id 831820cf +``` + +The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. + ### Always test new or modified code - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% From 0742f7835799d48a907d4436bb5875db61ecf4f6 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 19:50:49 -0400 Subject: [PATCH 016/941] docs: replace the markdown bug template with GitHub issue forms (#1894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: replace the markdown bug template with issue forms (#1844) Issues are the only intake channel now that external pull requests are off (#1820), so the chooser is where the contribution policy has to be stated — GitHub gives a would-be contributor no explanation when the "New pull request" button isn't there. The one existing template was a legacy **markdown** template, whose fields are prose headings a reporter can delete or ignore. Most reports still arrive missing the client and the transport, which are the two facts that decide where to look. Issue forms enforce them: this replaces `bug_report.md` with `1-bug_report.yml`, where client (web/cli/tui/core), version line (v1/v2), Inspector version, Node version, transport, MCP server, repro, expected, and actual are all `validations.required`. It auto-labels `bug`, and its intro says maintainers implement the fix, so a prompt plus screenshots beats a diff — with a field for exactly that. Adds `2-feature_request.yml` (auto-labels `enhancement`), which puts the problem statement first and marks the solution optional, since the problem is what survives when a specific solution turns out not to fit. Neither form applies a `v1`/`v2` label: GitHub can't map a form answer to a label, so the version line is a required dropdown and a maintainer still labels at triage. v1 takes security fixes only, so routing that answer early is the point. `config.yml` disables blank issues and carries the contact links. **There is deliberately no security *template*** — a template still opens a public issue, which is precisely what a vulnerability report must not do. The redirect is a contact link straight to the private advisory form (`/security/advisories/new`, verified enabled on this repo) plus one to `SECURITY.md`, which covers the v1 line too. The remaining links deflect the recurring misfiled classes: the specification repo and the TypeScript SDK for reports that aren't about this tool, the docs site, the contribution policy, and `#inspector-dev`. Also fixes three links the #1884 rename left dangling — `SECURITY.md` and the PR template still pointed at `CONTRIBUTORS.md`, which no longer exists on this branch. A policy nobody can open is the same as no policy. Validated against GitHub's issue-forms schema (unique ids, allowed `type`s, `markdown` blocks carrying no `id`/`validations`, `checkboxes` marking `required` per option rather than under `validations`). Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: address Copilot review on the issue forms (#1844) - PR template said `v2` targets `main`; the v2 base branch is `v2/main`. Correct on its face, and the one line in that template a reader acts on. - Scoped the Node floor on the bug form to v2, since the form serves both lines and v1's floor is older — reading `>= 22.19.0` as universal would make a legitimate v1 report look out of support. - Both the CONTRIBUTING.md paragraph and the AGENTS.md section now say the chooser is served from the **default branch**, so neither claims a form added on `v2/main` is live before the milestone merge. * docs: re-wrap the CONTRIBUTING chooser paragraph (#1844) Round-2 Copilot nit: the previous wrap left a dangling "The" at end of line, splitting a phrase mid-sentence in the raw markdown. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: clarify the protocol-era prompt on the bug form (#1844) Round-3 Copilot nit. "the protocol era (legacy / modern) if you selected one" reads as referring to a field on this form, which has none — the era is selected on the *connection*, in the Inspector's server settings. Reworded to "the protocol era you connected with (auto / legacy / modern)", which also picks up `auto`, the default the previous list omitted. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: stop describing the feature form as sharing the bug form's fields (#1844) Round-4 Copilot nits, and it was right three times over — I had described "client, version line, transport" as the required set for *both* forms in CONTRIBUTING.md, AGENTS.md, and the config.yml header. The feature form has neither a version-line dropdown nor a transport field, and cannot have the first: v1 takes security fixes only, so a feature request is v2 by construction. Each of the three now distinguishes the two forms rather than generalizing from the bug form. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: declare v2 statically on the feature form (#1844) Round-5 Copilot nit caught a real contradiction: the AGENTS.md paragraph said no form can apply a version label, then asserted the feature form "is always labeled v2". Both halves can't be true. Resolved in the direction that makes the claim true rather than softer. A form's `labels:` is static, which is only an obstacle when the value depends on a reporter's answer — and for the feature form it doesn't: v1 takes security fixes only, so a feature request is v2 by construction. So it now declares `["enhancement", "v2"]` outright, which also satisfies AGENTS.md's label-at-create-time rule without a triage step. The bug form still can't, and shouldn't: its version line is genuinely the reporter's to answer. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/ISSUE_TEMPLATE/1-bug_report.yml | 163 +++++++++++++++++++ .github/ISSUE_TEMPLATE/2-feature_request.yml | 88 ++++++++++ .github/ISSUE_TEMPLATE/bug_report.md | 73 --------- .github/ISSUE_TEMPLATE/config.yml | 57 +++++++ .github/pull_request_template.md | 6 +- AGENTS.md | 2 + CONTRIBUTING.md | 12 ++ SECURITY.md | 2 +- 8 files changed, 326 insertions(+), 77 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/1-bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/2-feature_request.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/1-bug_report.yml b/.github/ISSUE_TEMPLATE/1-bug_report.yml new file mode 100644 index 00000000..e46c7890 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug_report.yml @@ -0,0 +1,163 @@ +name: Bug report +description: Report something broken in the Inspector (web, CLI, or TUI) +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for reporting a bug — **issues are how work reaches the Inspector.** + + This repository accepts **issues, not pull requests**: design and + implementation are done by the maintainers through a prompt-driven + workflow ([`CONTRIBUTING.md`](https://github.com/modelcontextprotocol/inspector/blob/main/CONTRIBUTING.md)). + So if you have already prototyped a fix locally, **the prompt you used + plus screenshots is worth more to us than a diff** — there is a field + for it at the bottom of this form. + + > 🔒 **Do not report security vulnerabilities here.** Use the + > [private advisory process](https://github.com/modelcontextprotocol/inspector/security/advisories/new) + > instead — see [`SECURITY.md`](https://github.com/modelcontextprotocol/inspector/blob/main/SECURITY.md). + + - type: dropdown + id: version-line + attributes: + label: Which version line? + description: > + v2 is the current Inspector. v1 is deprecated and takes **security + fixes only**, so a v1 bug report gets a different answer. When in + doubt, it's v2. + options: + - v2 — current (`@modelcontextprotocol/inspector@latest`) + - v1 — deprecated (`@v1-latest`, security fixes only) + validations: + required: true + + - type: dropdown + id: client + attributes: + label: Which client? + description: The Inspector ships three clients from one package. + options: + - Web + - CLI + - TUI + - All / shared core + validations: + required: true + + - type: input + id: inspector-version + attributes: + label: Inspector version + description: The version you actually ran, not "latest". + placeholder: "2.0.0" + validations: + required: true + + - type: input + id: node-version + attributes: + label: Node version + description: > + Output of `node --version`. The current v2 line requires Node >= + 22.19.0; the deprecated v1 line has its own, older floor. + placeholder: "v22.19.0" + validations: + required: true + + - type: input + id: os + attributes: + label: Operating system (and browser, for the web client) + placeholder: "macOS 15.5, Chrome 140" + validations: + required: false + + - type: dropdown + id: transport + attributes: + label: Transport + description: How the Inspector was connected to the MCP server under inspection. + options: + - stdio + - Streamable HTTP + - SSE (legacy) + - Not applicable / never connected + validations: + required: true + + - type: textarea + id: mcp-server + attributes: + label: MCP server under inspection + description: > + Which server, and how it is configured — the command or URL, the + protocol era you connected with (auto / legacy / modern), and whether + OAuth is involved. Redact tokens and secrets. + placeholder: | + Server: @modelcontextprotocol/server-everything (npx) + Protocol era: modern + OAuth: no + validations: + required: true + + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Numbered steps, starting from a fresh launch of the Inspector. + placeholder: | + 1. Run `npx @modelcontextprotocol/inspector` + 2. Add the server above and connect + 3. Open the Tools tab and call `echo` + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What happened instead. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs, errors, or screenshots + description: > + Console output, the Inspector's Protocol or Network tab, or a + screenshot. Redact tokens and secrets first. Screenshots can be pasted + directly into this field. + validations: + required: false + + - type: textarea + id: prompt + attributes: + label: Already prototyped a fix? + description: > + Please don't attach a diff or open a pull request — share the **exact + prompt(s)** you used to produce the change, plus what you verified and + any before/after screenshots. We reproduce it through our own workflow + so it lands with the right conventions, tests, and coverage. + validations: + required: false + + - type: checkboxes + id: acknowledgements + attributes: + label: Before you submit + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: This is not a security vulnerability report (those go through the private advisory process). + required: true diff --git a/.github/ISSUE_TEMPLATE/2-feature_request.yml b/.github/ISSUE_TEMPLATE/2-feature_request.yml new file mode 100644 index 00000000..a5d3fefc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature_request.yml @@ -0,0 +1,88 @@ +name: Feature request +description: Suggest an improvement to the Inspector +# `v2` is safe to declare statically here: v1 takes security fixes only and +# cannot receive a feature, so a feature request is v2 by construction. The bug +# form can't do this — its version line is a reporter's answer, and `labels:` +# is static. +labels: ["enhancement", "v2"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement. + + **Maintainers do the implementation here** — this repository accepts + **issues, not pull requests** + ([`CONTRIBUTING.md`](https://github.com/modelcontextprotocol/inspector/blob/main/CONTRIBUTING.md)). + That makes a sharply-stated **problem** the most valuable thing you can + give us: it is what we design against, and it survives after a specific + solution turns out not to fit. + + So please describe the problem first, and treat the solution section as + one possible answer rather than the request itself. + + > **This form targets v2.** The v1 line is deprecated and takes + > **security fixes only** — it does not take new features. + + - type: dropdown + id: client + attributes: + label: Which client? + options: + - Web + - CLI + - TUI + - All / shared core + - Not sure + validations: + required: true + + - type: textarea + id: problem + attributes: + label: The problem + description: > + What are you trying to do, and what makes it hard or impossible today? + Describe the situation, not the feature — including how often you hit + it and what you do instead right now. + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Solution you have in mind (optional) + description: > + If you have a concrete idea, describe it here. It's fine to leave this + blank — the problem above is the part we need. + validations: + required: false + + - type: textarea + id: alternatives + attributes: + label: Alternatives or workarounds you have tried + validations: + required: false + + - type: textarea + id: prompt + attributes: + label: Already built it locally? + description: > + Please don't attach a diff or open a pull request — share the **exact + prompt(s)** you used, plus screenshots of the result. We reproduce the + work through our own workflow so it lands with the right conventions, + tests, and coverage. + validations: + required: false + + - type: checkboxes + id: acknowledgements + attributes: + label: Before you submit + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: This is a request for the Inspector itself, not for the MCP specification or an SDK. + required: true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index a038f066..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -name: Bug report -about: Report something broken in the Inspector -title: "" -labels: "" -assignees: "" ---- - -<!-- -Issues are how work reaches the Inspector — we accept issues, not pull -requests. See CONTRIBUTORS.md for the full policy. - -Just tick the version below — a maintainer applies the `v1` / `v2` label -during triage (most reporters can't set labels themselves). When in doubt, -it's v2. ---> - -**Which version?** - -- [ ] v2 — current, published as `@modelcontextprotocol/inspector@latest` -- [ ] v1 — deprecated, `@v1-latest` (security and bug fixes only) - -**Inspector version** - -<!-- e.g. 2.0.0 — the version you actually ran, not "latest" --> - -**Which client?** - -- [ ] Web -- [ ] CLI -- [ ] TUI -- [ ] All / shared core - -**What happened** - -<!-- The actual behavior you observed. --> - -**What you expected instead** - -**Steps to reproduce** - -1. -2. -3. - -<!-- -If it depends on a particular MCP server, say which one and how it's -configured — transport (stdio / streamable HTTP / SSE), protocol era -(legacy / modern), and whether OAuth is involved. ---> - -**Environment** - -- OS: -- Node version: -- Browser (web client only): -- MCP server under inspection: - -**Logs, errors, or screenshots** - -<!-- -Console output, the Inspector's Protocol or Network tab, or a screenshot. -Redact tokens and secrets first. ---> - -**Already prototyped a fix?** - -<!-- -Please don't attach a diff or open a PR. Share the *prompt* you used to -produce the change, plus what you verified — we'll reproduce it through our -own workflow so it lands with the right conventions, tests, and coverage. -See CONTRIBUTORS.md → "If you've already fixed it locally". ---> diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f2dddef1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,57 @@ +# Issue chooser configuration. +# +# Blank issues are disabled so every report lands in a form. The bug form +# requires the facts triage needs first (client, version line, transport); the +# feature form requires the client and a problem statement, and is v2-only. +# +# Security reports are deliberately a *contact link* rather than a template: +# a template would still open a public issue, which is exactly what a +# vulnerability report must not do. These links leave GitHub's issue flow and +# go straight to the private advisory form. See SECURITY.md. +blank_issues_enabled: false +contact_links: + - name: 🔒 Security vulnerability (v1 or v2) — report privately + url: https://github.com/modelcontextprotocol/inspector/security/advisories/new + about: > + Never report a vulnerability in a public issue. Private vulnerability + reporting is enabled on this repository — this link opens the advisory + form, which is the fastest route to a maintainer. v1 is deprecated and + takes security fixes only, so this is the channel for a v1 security + issue. + + - name: 📜 Security policy (supported versions, what to include) + url: https://github.com/modelcontextprotocol/inspector/blob/main/SECURITY.md + about: > + Which versions are supported, how reports are handled, and what to + include in one. + + - name: 🤝 Contribution policy — why there is no "New pull request" + url: https://github.com/modelcontextprotocol/inspector/blob/main/CONTRIBUTING.md + about: > + This repository accepts issues, not pull requests — maintainers do the + implementation. If you already built a change locally, open an issue and + share the prompt you used rather than a diff. + + - name: 📖 MCP documentation + url: https://modelcontextprotocol.io + about: > + Guides and reference for the Model Context Protocol itself. Start here + for "how does MCP work" questions. + + - name: 📐 MCP specification — protocol questions and proposals + url: https://github.com/modelcontextprotocol/modelcontextprotocol/issues + about: > + If the behavior you're reporting is defined by the protocol rather than + by this tool, file it against the specification repository instead. + + - name: 🧰 MCP TypeScript SDK + url: https://github.com/modelcontextprotocol/typescript-sdk/issues + about: > + The Inspector is built on the TypeScript SDK. If the bug reproduces + against the SDK directly (outside the Inspector), file it there. + + - name: 💬 MCP Contributor Discord (#inspector-dev) + url: https://discord.gg/6CSzBmMkjX + about: > + Questions, usage help, and day-to-day Inspector development discussion. + Also where to say hello if you'd like to work on the Inspector with us. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 029906db..5ce2a7ee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,20 +11,20 @@ If you've found a bug or want a feature: → Open an issue instead, labeled for the version you're targeting - (`v1` for `v1/main`, `v2` for `main`). + (`v1` for `v1/main`, `v2` for `v2/main`). If you've already built the change locally: → Open an issue and share the PROMPT(S) you used to generate it, not a diff. We'll reproduce it through our own workflow. - Full policy: ../CONTRIBUTORS.md (relative to this template — the file lives + Full policy: ../CONTRIBUTING.md (relative to this template — the file lives at the repository root, on whatever branch you're reading). Maintainers: delete this template body and describe your change normally. --> > **Heads up:** this repository accepts **issues, not pull requests** from -> external contributors. Please read [`CONTRIBUTORS.md`](../CONTRIBUTORS.md) +> external contributors. Please read [`CONTRIBUTING.md`](../CONTRIBUTING.md) > before continuing. If you're an external contributor, open an issue (and > share the prompt you used, if you've already built the change) rather than > this PR. diff --git a/AGENTS.md b/AGENTS.md index cdaacd0d..c67a38ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,8 @@ If you've already built a change locally, share the **prompt** you used and scre **This applies to org members with write access too, not just outside contributors.** Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the **repo maintainers** only. Anyone else — including organization members whose write access makes it technically possible — opens a **detailed issue** instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff. +**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line *dropdown* and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). + **Every PR must reference an issue. No exceptions, regardless of who opens it.** The PR body's first line is `Closes #<ISSUE_NUMBER>` (see the [Issue-driven Work Style](#issue-driven-work-style) rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature. ## Project Status and Direction diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index df3bda45..1976d078 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,18 @@ see [What makes a good issue or prompt submission](#what-makes-a-good-issue-or-p below. That's the whole process: you describe the intent, we handle the design and implementation. +[**New issue**](https://github.com/modelcontextprotocol/inspector/issues/new/choose) +offers a **Bug report** and a **Feature request** form. Blank issues are +disabled, so pick one of the two. The bug form requires the facts triage needs +first — which client, which version line, which transport; the feature form +asks for the client and the problem you are trying to solve, and targets v2 +only. +(GitHub serves the chooser from the repository's **default branch**, so what +you see when filing is whatever has reached `main`; a form added on `v2/main` +appears at the next milestone merge.) The same chooser links out to the private +security-advisory process, to this policy, and to the specification and SDK +repositories for reports that aren't about the Inspector itself. + ### Which version and label? The Inspector is maintained across two versions, each with its own base branch diff --git a/SECURITY.md b/SECURITY.md index 3e26f216..f0ec359f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,7 +38,7 @@ Please **do not** report security vulnerabilities through public GitHub issues, or pull requests. Note that this repository does not accept pull requests from outside contributors -(see [CONTRIBUTORS.md](./CONTRIBUTORS.md)) — **this does not apply to security +(see [CONTRIBUTING.md](./CONTRIBUTING.md)) — **this does not apply to security reports**, which should always go through the advisory process above rather than any public channel. From 23e0c39d117e0740cd27b40697e6f2c0217d93b5 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 19:50:57 -0400 Subject: [PATCH 017/941] chore(deps): untangle the vitest/Storybook exact-pin knot to clear GHSA-p63j-vcc4-9vmv (#1839) (#1899) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-p63j-vcc4-9vmv (**critical**) — `@vitest/browser` Browser Mode provider commands accept a file path from the browser and act on it without checking the `allowWrite` permission gate. Patched in 4.1.10; `clients/web` was pinned at 4.1.0. Dev-scope only — `@vitest/*` is absent from the published `files` allowlist, and Browser Mode here runs this project's own Storybook play functions, never a third-party page. That is why #1837 deferred it rather than attempting a toolchain upgrade immediately before an irreversible publish. ## The knot, and what actually unties it `^4.1.0` already admits 4.1.10, so the declared ranges were never the problem. The family is welded by *exact* peer pins — `vitest` pins `@vitest/browser-playwright`, which pins `@vitest/browser` — so nothing moves alone. The issue predicted that an explicit coordinated `npm i -D` would fix it. It does not. With `clients/web/package-lock.json` in place, npm ERESOLVEs even when every member of the family plus the Storybook packages are named in one command and `node_modules` is deleted first: Found: @storybook/addon-a11y@10.2.19 Could not resolve dependency: dev @storybook/addon-a11y@"^10.5.5" Conflicting peer dependency: storybook@10.5.5 npm resolves against the existing lock tree and will not move the whole constellation at once. **Regenerating `clients/web/package-lock.json` is the only thing that unties it** — a clean resolve places all four at 4.1.10 with no peer complaints at all. ## Versions vitest 4.1.0 -> 4.1.10 @vitest/browser 4.1.0 -> 4.1.10 (transitive) @vitest/browser-playwright 4.1.0 -> 4.1.10 @vitest/coverage-v8 4.1.0 -> 4.1.10 storybook + @storybook/* 10.2.19 -> 10.5.5 eslint-plugin-storybook 10.2.19 -> 10.5.5 @chromatic-com/storybook 5.0.1 -> 5.2.1 `clients/{cli,tui,launcher}` were already resolving 4.1.9 (no browser pins, so they floated freely) and were never vulnerable. Their declared `^4.1.0` still *permitted* 4.1.0 on a fresh resolve, so the floor is raised to `^4.1.10` in all four manifests — same reasoning as the vite floor in #1841. Pinning only the lockfile would have left a regression path open. ## The vite 8 peer override Resolved, as a consequence rather than a workaround. `@joshwooding/vite-plugin-react-docgen-typescript@0.6.4` declared `peer vite@"^3 || ^4 || ^5 || ^6 || ^7"` against our vite 8. It is not a direct dependency — it arrives through `@storybook/react-vite`, which at 10.5.5 depends on `^0.7.0`, and 0.7.0 adds `^8.0.0` to that range. So taking Storybook 10.5.5 for the peer set drops the override too; nothing is forced. ## Two holds the regeneration forced, each with a tracked issue Regenerating the lock floats every other in-range dependency to current. Most of that is welcome — it is what takes `clients/web` from 19 advisories to 1 — but two floats broke the gate and are unrelated to this security fix: - **zod 4.3.6 -> 4.4.3** makes `clients/web`'s `tsc -b` die with `FATAL ERROR: Ineffective mark-compacts near heap limit` at the ~4GB default. Reverting zod alone makes it exit 0, so the attribution is unambiguous. Held at `~4.3.6`. See #1896. - **eslint-plugin-react-hooks 7.0.1 -> 7.1.1** enables `set-state-in-effect`, which fails `lint` on 8 pre-existing violations across 7 components. Fixing them is a real refactor with interaction-behavior consequences, not a suppression. Held at `~7.0.1`. See #1897. Both `~` pins are deliberate and temporary, and both issues say so — they are constraints to remove, not preferences. Storybook 10.5.5 also reports that `setProjectAnnotations` is applied automatically since 10.3, making `.storybook/vitest.setup.ts` redundant. Left in place: `./preview` carries the Mantine decorator and the a11y annotations drive the play-function assertions, and if automatic provisioning missed either, the 462 stories would render unthemed with a11y checks inert and very likely still pass. That silent-failure risk does not belong in a security bump. See #1898. ## Audit delta `clients/web`, where the advisory lived: before 19 (4 critical, 6 high, 6 moderate, 3 low) after 1 (1 low — esbuild-in-tsup, Windows dev server only) All four critical entries — `vitest`, `@vitest/browser`, `@vitest/browser-playwright`, `@vitest/coverage-v8` — are gone. The other 14 are transitive dev-tree advisories (hono, ws, js-yaml, flatted, brace-expansion, ...) cleared as a side effect of the clean resolve. Elsewhere: cli 6 -> 5, launcher 3 -> 2, tui 4 -> 4, root unchanged at 9 (its lock is untouched). Those remainders are pre-existing and unrelated. ## Verification `npm run ci` green end to end on the upgraded stack — the point of caring here, since this *is* the test infrastructure: - coverage gate (>=90 per file, all four dimensions) passes on all four clients; no file regressed, so no gate was lowered and no `v8 ignore` added - 4802 web tests, 304 cli, 282 tui, 5 launcher - 462 Storybook play functions across 109 files, on vitest 4.1.10 Browser Mode - verify:build-gate, and all five smokes including `smoke:tui` (real TTY here) Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- clients/cli/package-lock.json | 486 ++-- clients/cli/package.json | 4 +- clients/launcher/package-lock.json | 433 ++-- clients/launcher/package.json | 4 +- clients/tui/package-lock.json | 128 +- clients/tui/package.json | 4 +- clients/web/package-lock.json | 3488 ++++++++++++++++++++-------- clients/web/package.json | 26 +- 8 files changed, 3080 insertions(+), 1493 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index d9a12d31..9018de13 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -28,14 +28,14 @@ "@eslint/js": "^9.39.4", "@types/express": "^5.0.6", "@types/node": "^24.12.4", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "globals": "^17.4.0", "prettier": "3.8.4", "tsup": "^8.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } }, "node_modules/@babel/helper-string-parser": { @@ -99,21 +99,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "dev": true, "license": "MIT", "optional": true, @@ -122,9 +122,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -1150,28 +1150,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1185,9 +1188,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -1202,9 +1205,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -1219,9 +1222,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -1236,9 +1239,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -1253,9 +1256,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -1270,13 +1273,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1287,13 +1293,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1304,13 +1313,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1321,13 +1333,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1338,13 +1353,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1355,13 +1373,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1372,9 +1393,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -1389,28 +1410,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -1425,9 +1443,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -1806,9 +1824,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2229,14 +2247,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2250,8 +2268,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2260,16 +2278,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2278,13 +2296,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2305,9 +2323,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2318,13 +2336,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -2332,14 +2350,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2348,9 +2366,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -2358,13 +2376,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2943,9 +2961,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -3244,9 +3262,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3945,9 +3963,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3961,23 +3979,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3996,9 +4014,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -4017,9 +4035,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -4038,9 +4056,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -4059,9 +4077,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -4080,13 +4098,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4101,13 +4122,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4122,13 +4146,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4143,13 +4170,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4164,9 +4194,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -4185,9 +4215,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4401,9 +4431,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -4459,9 +4489,9 @@ } }, "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4640,9 +4670,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -4721,9 +4751,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -4741,7 +4771,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4948,13 +4978,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4964,21 +4994,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/rollup": { @@ -5295,9 +5325,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5439,9 +5469,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -5684,16 +5714,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -5710,7 +5740,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5762,19 +5792,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5802,12 +5832,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5852,9 +5882,9 @@ } }, "node_modules/vitest/node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { diff --git a/clients/cli/package.json b/clients/cli/package.json index eb37684f..c352af6c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -49,13 +49,13 @@ "@eslint/js": "^9.39.4", "@types/express": "^5.0.6", "@types/node": "^24.12.4", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "globals": "^17.4.0", "prettier": "3.8.4", "tsup": "^8.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } } diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 4c112ddc..c66bfe68 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -16,13 +16,13 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.4", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "globals": "^17.4.0", "prettier": "3.8.4", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } }, "node_modules/@babel/helper-string-parser": { @@ -76,21 +76,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "2.0.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", "dev": true, "license": "MIT", "optional": true, @@ -99,9 +99,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", "dev": true, "license": "MIT", "optional": true, @@ -323,26 +323,31 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { "@tybys/wasm-util": "^0.10.3" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -350,9 +355,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", "cpu": [ "arm64" ], @@ -367,7 +372,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", "cpu": [ "arm64" ], @@ -382,9 +389,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", "cpu": [ "x64" ], @@ -399,9 +406,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", "cpu": [ "x64" ], @@ -416,9 +423,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", "cpu": [ "arm" ], @@ -433,9 +440,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", "cpu": [ "arm64" ], @@ -453,9 +460,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", "cpu": [ "arm64" ], @@ -473,9 +480,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", "cpu": [ "ppc64" ], @@ -493,9 +500,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", "cpu": [ "s390x" ], @@ -513,9 +520,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", "cpu": [ "x64" ], @@ -533,9 +540,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", "cpu": [ "x64" ], @@ -553,9 +560,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", "cpu": [ "arm64" ], @@ -570,28 +577,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", "cpu": [ "arm64" ], @@ -606,9 +610,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", "cpu": [ "x64" ], @@ -624,11 +628,15 @@ }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -645,6 +653,8 @@ }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -654,6 +664,8 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, @@ -928,12 +940,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -947,8 +961,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -957,14 +971,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -973,11 +989,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -998,7 +1016,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1009,11 +1029,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -1021,12 +1043,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1035,7 +1059,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -1043,11 +1069,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1110,6 +1138,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1150,6 +1180,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1201,6 +1233,8 @@ }, "node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, @@ -1240,6 +1274,8 @@ }, "node_modules/detect-libc": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1247,7 +1283,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -1409,7 +1447,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1492,7 +1532,10 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1687,7 +1730,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -1701,23 +1746,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -1736,7 +1781,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -1755,9 +1802,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -1776,9 +1823,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -1797,9 +1844,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -1818,9 +1865,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -1842,9 +1889,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -1866,9 +1913,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -1890,9 +1937,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -1914,9 +1961,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -1935,9 +1982,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -1976,6 +2023,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2023,7 +2072,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.14", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2045,7 +2096,9 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.2", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2129,16 +2182,22 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -2149,7 +2208,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "dev": true, "funding": [ { @@ -2167,7 +2228,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2214,11 +2275,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2228,21 +2291,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, "node_modules/semver": { @@ -2277,6 +2340,8 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, @@ -2290,11 +2355,15 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -2322,11 +2391,15 @@ }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { @@ -2349,7 +2422,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -2434,14 +2509,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -2458,7 +2535,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2510,17 +2587,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2548,12 +2627,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -2613,6 +2692,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { diff --git a/clients/launcher/package.json b/clients/launcher/package.json index f39eecdd..55ec6b47 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -28,12 +28,12 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.4", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "globals": "^17.4.0", "prettier": "3.8.4", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } } diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 90e9ff32..2117a80c 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -29,7 +29,7 @@ "@eslint/js": "^9.39.4", "@types/node": "^24.12.4", "@types/react": "^19.2.14", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.4.0", @@ -41,7 +41,7 @@ "typescript-eslint": "^8.56.1", "vite": "^8.1.5", "vite-node": "^6.0.0", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -2376,14 +2376,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2397,8 +2397,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2407,16 +2407,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2425,13 +2425,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.9", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2452,9 +2452,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2465,13 +2465,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.9", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -2479,14 +2479,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2495,9 +2495,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -2505,13 +2505,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.9", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3438,9 +3438,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5304,9 +5304,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -5503,9 +5503,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -6351,19 +6351,19 @@ } }, "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6391,12 +6391,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6441,9 +6441,9 @@ } }, "node_modules/vitest/node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", "engines": { diff --git a/clients/tui/package.json b/clients/tui/package.json index 77a018f5..e6849426 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -48,7 +48,7 @@ "@eslint/js": "^9.39.4", "@types/node": "^24.12.4", "@types/react": "^19.2.14", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "globals": "^17.4.0", @@ -60,6 +60,6 @@ "typescript-eslint": "^8.56.1", "vite": "^8.1.5", "vite-node": "^6.0.0", - "vitest": "^4.1.0" + "vitest": "^4.1.10" } } diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 999fcbe5..2a5154dc 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -1,6 +1,5 @@ { "name": "@modelcontextprotocol/inspector-web", - "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -36,19 +35,19 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "^4.3.6" + "zod": "~4.3.6" }, "bin": { "mcp-inspector-web": "build/index.js" }, "devDependencies": { - "@chromatic-com/storybook": "^5.0.1", + "@chromatic-com/storybook": "^5.2.1", "@eslint/js": "^9.39.4", - "@storybook/addon-a11y": "^10.2.19", - "@storybook/addon-docs": "^10.2.19", - "@storybook/addon-onboarding": "^10.2.19", - "@storybook/addon-vitest": "^10.2.19", - "@storybook/react-vite": "^10.2.19", + "@storybook/addon-a11y": "^10.5.5", + "@storybook/addon-docs": "^10.5.5", + "@storybook/addon-onboarding": "^10.5.5", + "@storybook/addon-vitest": "^10.5.5", + "@storybook/react-vite": "^10.5.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -59,40 +58,40 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "^4.1.0", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "~7.0.1", "eslint-plugin-react-refresh": "^0.5.2", - "eslint-plugin-storybook": "^10.2.19", + "eslint-plugin-storybook": "^10.5.5", "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", "prettier": "3.8.4", - "storybook": "^10.2.19", + "storybook": "^10.5.5", "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", "vite": "^8.1.5", - "vitest": "^4.1.0", + "vitest": "^4.1.10", "yaml": "^2.9.0" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -101,9 +100,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -111,21 +110,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -141,14 +140,31 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -158,14 +174,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -174,38 +190,48 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -215,27 +241,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -243,26 +269,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -272,40 +298,40 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", - "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -313,13 +339,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -343,15 +369,14 @@ "license": "MIT" }, "node_modules/@chromatic-com/storybook": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.0.1.tgz", - "integrity": "sha512-v80QBwVd8W6acH5NtDgFlUevIBaMZAh1pYpBiB40tuNzS242NTHeQHBDGYwIAbWKDnt1qfjJpcpL6pj5kAr4LA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@chromatic-com/storybook/-/storybook-5.2.1.tgz", + "integrity": "sha512-z6I7NJk/0VngA64y5TNYaB4Hc2X8+90n4op6lBt9PvWk5TmIlFLDqdX33rlrwbNRkkYijVgA/wO04rVYXi5Mlg==", "dev": true, "license": "MIT", "dependencies": { "@neoconfetti/react": "^1.0.0", - "chromatic": "^13.3.4", - "filesize": "^10.0.12", + "chromatic": "16.10.0", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, @@ -360,7 +385,7 @@ "yarn": ">=1.22.18" }, "peerDependencies": { - "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0" + "storybook": "^0.0.0-0 || ^10.1.0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 || ^10.4.0-0 || ^10.5.0-0 || ^10.6.0-0" } }, "node_modules/@dnd-kit/accessibility": { @@ -417,21 +442,21 @@ } }, "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "2.0.1", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -440,9 +465,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -469,12 +494,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT" - }, "node_modules/@emotion/cache": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", @@ -571,9 +590,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -588,9 +607,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -605,9 +624,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -622,9 +641,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -639,9 +658,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -656,9 +675,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -673,9 +692,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -690,9 +709,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -707,9 +726,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -724,9 +743,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -741,9 +760,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -758,9 +777,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -775,9 +794,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -792,9 +811,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -809,9 +828,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -826,9 +845,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -843,9 +862,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -860,9 +879,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -877,9 +896,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -894,9 +913,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -911,9 +930,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -928,9 +947,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -945,9 +964,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -962,9 +981,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -979,9 +998,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -996,9 +1015,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1013,9 +1032,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1096,9 +1115,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1108,7 +1127,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1120,9 +1139,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1157,9 +1176,9 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1194,32 +1213,32 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react": { - "version": "0.27.19", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", - "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -1228,12 +1247,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1241,9 +1260,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@hono/node-server": { @@ -1259,29 +1278,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1310,6 +1343,26 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^13.0.1", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1357,9 +1410,9 @@ } }, "node_modules/@mantine/core": { - "version": "8.3.17", - "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.17.tgz", - "integrity": "sha512-JoZdnEKz8TVPdY0bjo8kx1M6KPHZ61yZJkMv2hFx3LexI4+NFKcZa1dmWbfRvy2FptMCR6ZLbJdqvbovoYTFJw==", + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.18.tgz", + "integrity": "sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.27.16", @@ -1370,15 +1423,15 @@ "type-fest": "^4.41.0" }, "peerDependencies": { - "@mantine/hooks": "8.3.17", + "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/form": { - "version": "8.3.17", - "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.17.tgz", - "integrity": "sha512-UKLT2KztUtGhHN9QN83OaqfqCotEZeHyQmuFL4cPFExuBaGovDPohsgxnafblVuTvkiq0Sk00ZC31iVByT74UA==", + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-8.3.18.tgz", + "integrity": "sha512-r5OGLJWTkmIruFjRZRZy9oA7maNYlyt50jB4Pmd2X5360WOmJLd4KH8MFhHZQC7vN+z8/rmBl3t3XGAR2I8xig==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1389,34 +1442,34 @@ } }, "node_modules/@mantine/hooks": { - "version": "8.3.17", - "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.17.tgz", - "integrity": "sha512-MqVw4ad0qrcY7IgVk8+GJh7qCRIwLXTfEtGeXwmaZagMDn2YB8EUtrnuZO2be5bi3yketWtOPYvl3adh05hZRg==", + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.18.tgz", + "integrity": "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" } }, "node_modules/@mantine/notifications": { - "version": "8.3.17", - "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.17.tgz", - "integrity": "sha512-ZTvmBbVsprjPEKaixfxc1HLBokIaojHvLTVNQNq76H9hF4e9vgu/gwWs3AQeHksZvExByW1ok7XR6blCKqvTxA==", + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-8.3.18.tgz", + "integrity": "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==", "license": "MIT", "dependencies": { - "@mantine/store": "8.3.17", + "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { - "@mantine/core": "8.3.17", - "@mantine/hooks": "8.3.17", + "@mantine/core": "8.3.18", + "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "node_modules/@mantine/store": { - "version": "8.3.17", - "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.17.tgz", - "integrity": "sha512-uwI/0lhzSwdfdzqA6YlnlNP/9KjUc5SxbuB3OH/P8aasn2utsVNsKIYDlAU/oFsjggSAoQxzEGR1V2ktTjLlXg==", + "version": "8.3.18", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-8.3.18.tgz", + "integrity": "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==", "license": "MIT", "peerDependencies": { "react": "^18.x || ^19.x" @@ -1471,9 +1524,9 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", "license": "MIT", "workspaces": [ "examples/*" @@ -1500,13 +1553,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1540,19 +1593,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -1691,6 +1731,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1707,6 +1750,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1723,6 +1769,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1739,6 +1788,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1755,6 +1807,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1812,6 +1867,26 @@ "node": ">= 10" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -1841,16 +1916,711 @@ "dev": true, "license": "MIT" }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -2102,6 +2872,40 @@ "node": "^20.19.0 || ^22.13.0 || >=23.5.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", @@ -2137,16 +2941,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", - "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", "dev": true, "license": "MIT", "dependencies": { @@ -2167,9 +2971,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -2181,9 +2985,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -2195,9 +2999,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -2209,9 +3013,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -2223,9 +3027,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -2237,9 +3041,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -2251,13 +3055,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2265,13 +3072,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2279,13 +3089,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2293,13 +3106,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2307,13 +3123,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2321,13 +3140,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2335,13 +3157,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2349,13 +3174,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2363,13 +3191,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2377,13 +3208,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2391,13 +3225,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2405,13 +3242,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2419,13 +3259,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2433,9 +3276,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -2447,9 +3290,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -2461,9 +3304,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -2475,9 +3318,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -2489,9 +3332,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -2503,9 +3346,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -2523,9 +3366,9 @@ "license": "MIT" }, "node_modules/@storybook/addon-a11y": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.2.19.tgz", - "integrity": "sha512-SJGf1ghCoRVlwyiRwz5GiHuNvu7C5iCDNIRJW8WGOJlnoQa3rYaY7WJ/8a/eT9N8buIscL9AYWudhh5zsI1W3g==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.5.5.tgz", + "integrity": "sha512-nsMnSRe7pzepXIkUUqI/rL7sp8juXOluyypU4Dz0UuYHhw/cKaxfzuO+3WJN5EtEr/gcnKYb4awxH/duPGavUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2537,20 +3380,20 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.19" + "storybook": "^10.5.5" } }, "node_modules/@storybook/addon-docs": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.2.19.tgz", - "integrity": "sha512-tXugthdzjX5AkGWDSP4pnRgA/CWlOaEKp/+y9JOGXHLQmm1GHjW+4brNvNkKbjBl06LALXwlcTOyU4lyVRDLAw==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.5.5.tgz", + "integrity": "sha512-0YpKlimS4XE0kQ8Maa5coeefQxdyDrBHg1wOP3WTPuBe4FolFSCDveR0ge2+vuUBk+fZfn2+l+3Q2jmAWaRGDg==", "dev": true, "license": "MIT", "dependencies": { "@mdx-js/react": "^3.0.0", - "@storybook/csf-plugin": "10.2.19", - "@storybook/icons": "^2.0.1", - "@storybook/react-dom-shim": "10.2.19", + "@storybook/csf-plugin": "10.5.5", + "@storybook/icons": "^2.0.2", + "@storybook/react-dom-shim": "10.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" @@ -2560,13 +3403,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.19" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.5.5" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@storybook/addon-onboarding": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-10.2.19.tgz", - "integrity": "sha512-nlsQrdVMRFpbdimouLJHmCOCSsa3J1ASgDT6ipuE1owpUltg7Dj2PdUg/JRaWE6ghlP7MOP5see6vvnUgZ0Feg==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-onboarding/-/addon-onboarding-10.5.5.tgz", + "integrity": "sha512-QF8mn5QDJdY1LI/RqpxlruaETrPspVFVMRl0oFLSYOIQENIKLY+9Jy48zBkEeiy2vVY8aTNeWdkrnFpDc9G2qA==", "dev": true, "license": "MIT", "funding": { @@ -2574,18 +3423,18 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.19" + "storybook": "^10.5.5" } }, "node_modules/@storybook/addon-vitest": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.2.19.tgz", - "integrity": "sha512-mx7B8QBT4YFLJ6+30rVxRfJNMDrIfVjJBWtdhP4mbZCS2IuoKAqf28KaC4ZZ4/UWAxjA/8VSQrV9CpXArepMlg==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/addon-vitest/-/addon-vitest-10.5.5.tgz", + "integrity": "sha512-Ymq9ErkSkYiIDuqpJ2+hE5GCQ5J6TCLOWhutqArvwaeAO+HAibM82XNExpJ1/kvPqk9y961GDPkv2W15I88JIw==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1" + "@storybook/icons": "^2.0.2" }, "funding": { "type": "opencollective", @@ -2595,7 +3444,7 @@ "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", - "storybook": "^10.2.19", + "storybook": "^10.5.5", "vitest": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { @@ -2614,13 +3463,13 @@ } }, "node_modules/@storybook/builder-vite": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.2.19.tgz", - "integrity": "sha512-a59xALzM9GeYh6p+wzAeBbDyIe+qyrC4nxS3QNzb5i2ZOhrq1iIpvnDaOWe80NC8mV3IlqUEGY8Uawkf//1Rmg==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.5.5.tgz", + "integrity": "sha512-dQoJ7gUl8y0z5rV9cE0mz6qTBNmN9R4GOLIZk98rJ8CwduNJOb9eGZXusDzzvnYcp8TnNkqDtyx4tXQSUDInPQ==", "dev": true, "license": "MIT", "dependencies": { - "@storybook/csf-plugin": "10.2.19", + "@storybook/csf-plugin": "10.5.5", "ts-dedent": "^2.0.0" }, "funding": { @@ -2628,14 +3477,14 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "storybook": "^10.2.19", + "storybook": "^10.5.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/@storybook/csf-plugin": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.2.19.tgz", - "integrity": "sha512-BpjYIOdyQn/Rm6MjUAc5Gl8HlARZrskD/OhUNShiOh2fznb523dHjiE5mbU1kKM/+L1uvRlEqqih40rTx+xCrg==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-10.5.5.tgz", + "integrity": "sha512-/euibhRFqklYCZqUseokojmfYcQpXshVY2QmA1qCuxMz9SzVFD3iSTw+aFLTxpsJGGdcZJk8fnm/rEthLzZ9jA==", "dev": true, "license": "MIT", "dependencies": { @@ -2648,7 +3497,7 @@ "peerDependencies": { "esbuild": "*", "rollup": "*", - "storybook": "^10.2.19", + "storybook": "^10.5.5", "vite": "*", "webpack": "*" }, @@ -2675,47 +3524,55 @@ "license": "MIT" }, "node_modules/@storybook/icons": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.0.1.tgz", - "integrity": "sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz", + "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==", "dev": true, "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@storybook/react": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.2.19.tgz", - "integrity": "sha512-gm2qxLyYSsGp7fee5i+d8jSVUKMla8yRaTJ1wxPEnyaJMd0QUu6U2v3p2rW7PH1DWop3D6NqWOY8kmZjmSZKlA==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.5.5.tgz", + "integrity": "sha512-T2Xj0ey7a9RHU6coYLC0L5lhjcdyhLCs9wNv15FvHvgmrRobkynEV72kq5vGW8tFkahNWI1X9+GZPQ6r8Nm38w==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/react-dom-shim": "10.2.19", - "react-docgen": "^8.0.2" + "@storybook/react-dom-shim": "10.5.5", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.19", + "storybook": "^10.5.5", "typescript": ">= 4.9.x" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, "typescript": { "optional": true } } }, "node_modules/@storybook/react-dom-shim": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.2.19.tgz", - "integrity": "sha512-BXCEfBGVBRYBTYeBeH/PJsy0Bq5MERe/HiaylR+ah/XrvIr2Z9bkne1J8yYiXCjiyq5HQa7Bj11roz0+vyUaEw==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.5.5.tgz", + "integrity": "sha512-PIk7N3LLrZIxfNxmkvmQN1d5UQ70XEedT8n0GhBiXnM6XL09xPGB8n8TZXeJBRYluKhDQcAyQeT0/OZmcDVQJg==", "dev": true, "license": "MIT", "funding": { @@ -2723,25 +3580,35 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.19" + "storybook": "^10.5.5" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@storybook/react-vite": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.2.19.tgz", - "integrity": "sha512-2/yMKrK4IqMIZicRpPMoIg+foBuWnkaEWt0R4V4hjErDj/SC3D9ov+GUqhjKJ81TegijhKzNpwnSD7Nf87haKw==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.5.5.tgz", + "integrity": "sha512-Uy7VV72kVSkw6aDTAPQupXUeZX5LF6e4zqNvTZ+36qxsXAkaFgw7HPEm7L1tsaRfiV+s9anU7UvX47tfJpYGuQ==", "dev": true, "license": "MIT", "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "^0.6.4", + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "10.2.19", - "@storybook/react": "10.2.19", + "@storybook/builder-vite": "10.5.5", + "@storybook/react": "10.5.5", "empathic": "^2.0.0", "magic-string": "^0.30.0", - "react-docgen": "^8.0.0", + "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, @@ -2752,23 +3619,9 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^10.2.19", + "storybook": "^10.5.5", + "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@storybook/react-vite/node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.4.tgz", - "integrity": "sha512-6PyZBYKnnVNqOSB0YFly+62R7dmov8segT27A+RVTBVd4iAE6kbW9QBJGlyR2yG4D4ohzhZSTIu7BK1UTtmFFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^13.0.1", - "react-docgen-typescript": "^2.2.2" - }, - "peerDependencies": { - "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2782,7 +3635,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2882,8 +3734,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3013,9 +3864,9 @@ } }, "node_modules/@types/express-serve-static-core": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", - "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "dependencies": { @@ -3026,9 +3877,9 @@ } }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -3058,9 +3909,9 @@ } }, "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", "dev": true, "license": "MIT" }, @@ -3071,13 +3922,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", - "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/papaparse": { @@ -3117,18 +3968,18 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3197,20 +4048,20 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", - "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/type-utils": "8.57.0", - "@typescript-eslint/utils": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3220,15 +4071,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.0", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -3236,16 +4087,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", - "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -3257,18 +4108,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", - "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.0", - "@typescript-eslint/types": "^8.57.0", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -3279,18 +4130,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", - "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3301,9 +4152,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", - "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -3314,21 +4165,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", - "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3339,13 +4190,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", - "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -3357,21 +4208,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", - "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.0", - "@typescript-eslint/tsconfig-utils": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/visitor-keys": "8.57.0", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3381,7 +4232,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { @@ -3395,26 +4246,26 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "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": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -3423,30 +4274,17 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", - "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.0", - "@typescript-eslint/types": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3457,17 +4295,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", - "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3492,19 +4330,19 @@ } }, "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", - "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-rc.7" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -3524,45 +4362,45 @@ } }, "node_modules/@vitest/browser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.0.tgz", - "integrity": "sha512-tG/iOrgbiHQks0ew7CdelUyNEHkv8NLrt+CqdTivIuoSnXvO7scWMn4Kqo78/UGY1NJ6Hv+vp8BvRnED/bjFdQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", + "integrity": "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==", "dev": true, "license": "MIT", "dependencies": { "@blazediff/core": "1.9.1", - "@vitest/mocker": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/mocker": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", - "tinyrainbow": "^3.0.3", + "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.0" + "vitest": "4.1.10" } }, "node_modules/@vitest/browser-playwright": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.0.tgz", - "integrity": "sha512-2RU7pZELY9/aVMLmABNy1HeZ4FX23FXGY1jRuHLHgWa2zaAE49aNW2GLzebW+BmbTZIKKyFF1QXvk7DEWViUCQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.10.tgz", + "integrity": "sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/browser": "4.1.0", - "@vitest/mocker": "4.1.0", - "tinyrainbow": "^3.0.3" + "@vitest/browser": "4.1.10", + "@vitest/mocker": "4.1.10", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "playwright": "*", - "vitest": "4.1.0" + "vitest": "4.1.10" }, "peerDependenciesMeta": { "playwright": { @@ -3571,14 +4409,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -3586,14 +4424,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3670,13 +4508,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3685,7 +4523,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3707,26 +4545,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.10", "pathe": "^2.0.3" }, "funding": { @@ -3734,14 +4572,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3750,9 +4588,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, "license": "MIT", "funding": { @@ -3760,20 +4598,34 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webcontainer/env": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -3788,9 +4640,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -3811,9 +4663,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3850,7 +4702,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3919,9 +4770,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", "dev": true, "license": "MIT", "dependencies": { @@ -3967,9 +4818,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -4009,9 +4860,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.8", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", - "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "version": "2.11.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.10.tgz", + "integrity": "sha512-35JEvJ5/KKlbCHjMCsONI2w6HE88STjVdHk+C7d8LtcFxUjZR1KeLP9izofn2qs0KUxX5r4z73bwH/rd+JHacw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4022,20 +4873,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -4045,10 +4896,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4057,9 +4921,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -4077,11 +4941,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -4090,6 +4954,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -4179,9 +5056,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001779", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", - "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -4309,19 +5186,23 @@ } }, "node_modules/chromatic": { - "version": "13.3.5", - "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-13.3.5.tgz", - "integrity": "sha512-MzPhxpl838qJUo0A55osCF2ifwPbjcIPeElr1d4SHcjnHoIcg7l1syJDrAYK/a+PcCBrOGi06jPNpQAln5hWgw==", + "version": "16.10.0", + "resolved": "https://registry.npmjs.org/chromatic/-/chromatic-16.10.0.tgz", + "integrity": "sha512-nFsztmnu7rFiGafUJgXvLUNpqmRylz92eNvzBoJNTKKQj4EQUyxznwnfpf1dTs7hXtWD8JwcH92jADydaHA1sw==", "dev": true, "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, "bin": { - "chroma": "dist/bin.js", - "chromatic": "dist/bin.js", - "chromatic-cli": "dist/bin.js" + "chroma": "dist/bin.cjs", + "chromatic": "dist/bin.cjs", + "chromatic-cli": "dist/bin.cjs" }, "peerDependencies": { "@chromatic-com/cypress": "^0.*.* || ^1.0.0", - "@chromatic-com/playwright": "^0.*.* || ^1.0.0" + "@chromatic-com/playwright": "^0.*.* || ^1.0.0", + "@chromatic-com/vitest": "^0.*.* || ^1.0.0" }, "peerDependenciesMeta": { "@chromatic-com/cypress": { @@ -4329,6 +5210,9 @@ }, "@chromatic-com/playwright": { "optional": true + }, + "@chromatic-com/vitest": { + "optional": true } } }, @@ -4427,10 +5311,9 @@ } }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, "node_modules/cookie": { @@ -4485,9 +5368,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -4672,8 +5555,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -4706,16 +5588,16 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.313", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", - "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", "dev": true, "license": "ISC" }, "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", "dev": true, "license": "MIT", "engines": { @@ -4772,16 +5654,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4791,9 +5673,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4804,32 +5686,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -4861,9 +5743,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -4872,8 +5754,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4941,9 +5823,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4951,17 +5833,18 @@ } }, "node_modules/eslint-plugin-storybook": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.2.19.tgz", - "integrity": "sha512-JwwNgG24mkwwiJp/VIwUuJ9QIXoeCZteSZ7PEpb8DUhKpzCrNxJOeg7i5ep6yLexWAVjfFLG4OnFeV8cVS2PAg==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-10.5.5.tgz", + "integrity": "sha512-xGCrddoZ8pMmwc7M5mp+habWbvqBjRuyGVTJ6RpavpoAP6NBUfGSRXHuBs7cHRDpDSuErYeDiEo4XHTonRp68g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.48.0" + "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/utils": "^8.60.0" }, "peerDependencies": { "eslint": ">=8", - "storybook": "^10.2.19" + "storybook": "^10.5.5" } }, "node_modules/eslint-scope": { @@ -4995,9 +5878,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5135,18 +6018,18 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { "node": ">=18.0.0" } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5197,12 +6080,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -5241,9 +6125,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5300,16 +6184,6 @@ "node": ">=16.0.0" } }, - "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 10.4.0" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -5381,9 +6255,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -5414,9 +6288,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5535,26 +6409,26 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "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/glob/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -5564,9 +6438,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -5597,18 +6471,19 @@ "optional": true }, "node_modules/happy-dom": { - "version": "20.9.0", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz", - "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" + "ws": "^8.21.0" }, "engines": { "node": ">=20.0.0" @@ -5637,9 +6512,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5760,9 +6635,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.33", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", + "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -5806,9 +6681,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5880,9 +6755,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -5928,12 +6803,12 @@ "license": "MIT" }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6097,9 +6972,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", + "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -6122,10 +6997,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "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": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6192,10 +7077,17 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6622,7 +7514,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6638,14 +7529,14 @@ } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, @@ -6665,19 +7556,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -6980,12 +7858,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -7708,11 +8590,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -7736,15 +8621,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/on-exit-leak-free": { "version": "2.1.2", @@ -7812,6 +8700,75 @@ "node": ">= 0.8.0" } }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7957,9 +8914,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8090,50 +9047,35 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/pngjs": { @@ -8250,7 +9192,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8266,7 +9207,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8279,8 +9219,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", @@ -8292,9 +9231,9 @@ } }, "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", "funding": [ { "type": "github", @@ -8319,9 +9258,9 @@ } }, "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", "funding": { "type": "github", @@ -8352,12 +9291,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -8373,12 +9313,16 @@ "license": "MIT" }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -8397,9 +9341,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8438,21 +9382,21 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.8" } }, "node_modules/react-icons": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", - "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -8492,9 +9436,9 @@ } }, "node_modules/react-number-format": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz", - "integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", "license": "MIT", "peerDependencies": { "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -8646,9 +9590,9 @@ } }, "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "version": "0.23.19", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.19.tgz", + "integrity": "sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==", "dev": true, "license": "MIT", "dependencies": { @@ -8791,11 +9735,12 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8853,17 +9798,20 @@ "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } }, "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { @@ -8877,31 +9825,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -8955,13 +9904,16 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -9037,14 +9989,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -9194,31 +10146,36 @@ } }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, "node_modules/storybook": { - "version": "10.2.19", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.2.19.tgz", - "integrity": "sha512-UUm5eGSm6BLhkcFP0WbxkmAHJZfVN2ViLpIZOqiIPS++q32VYn+CLFC0lrTYTDqYvaG7i4BK4uowXYujzE4NdQ==", + "version": "10.5.5", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.5.5.tgz", + "integrity": "sha512-UscBIBJDloUeqntukHOhP1a5W/vouePDJbzPSxj466WK801FZtzQiMffMtkjzJiWSuj20wfaYlB2QQKh9aOYAg==", "dev": true, "license": "MIT", "dependencies": { "@storybook/global": "^5.0.0", - "@storybook/icons": "^2.0.1", - "@testing-library/jest-dom": "^6.6.3", + "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", + "@webcontainer/env": "^1.1.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", + "jsonc-parser": "^3.3.1", "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", - "ws": "^8.18.0" + "ws": "^8.21.1" }, "bin": { "storybook": "dist/bin/dispatcher.js" @@ -9228,11 +10185,19 @@ "url": "https://opencollective.com/storybook" }, "peerDependencies": { - "prettier": "^2 || ^3" + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "prettier": "^2 || ^3", + "vite-plus": "^0.1.15 || ^0.2.0" }, "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, "prettier": { "optional": true + }, + "vite-plus": { + "optional": true } } }, @@ -9249,19 +10214,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/storybook/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -9439,9 +10391,9 @@ } }, "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", "license": "MIT" }, "node_modules/thenify": { @@ -9468,9 +10420,9 @@ } }, "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", "license": "MIT", "dependencies": { "real-require": "^0.2.0" @@ -9491,14 +10443,11 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.17", @@ -9518,9 +10467,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -9587,9 +10536,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -9600,9 +10549,9 @@ } }, "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", "dev": true, "license": "MIT", "engines": { @@ -9690,6 +10639,490 @@ } } }, + "node_modules/tsup/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsup/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/tsup/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -9710,13 +11143,6 @@ "node": ">= 12" } }, - "node_modules/tsup/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9743,17 +11169,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -9771,16 +11214,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", - "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.57.0", - "@typescript-eslint/parser": "8.57.0", - "@typescript-eslint/typescript-estree": "8.57.0", - "@typescript-eslint/utils": "8.57.0" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9791,7 +11234,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/ufo": { @@ -9802,9 +11245,9 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -10184,20 +11627,35 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10208,8 +11666,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -10225,13 +11683,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -10252,6 +11712,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -10267,18 +11733,18 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -10294,6 +11760,16 @@ "node": ">=18" } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -10366,9 +11842,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { diff --git a/clients/web/package.json b/clients/web/package.json index 81a8d4af..4837502f 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -61,16 +61,16 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "^4.3.6" + "zod": "~4.3.6" }, "devDependencies": { - "@chromatic-com/storybook": "^5.0.1", + "@chromatic-com/storybook": "^5.2.1", "@eslint/js": "^9.39.4", - "@storybook/addon-a11y": "^10.2.19", - "@storybook/addon-docs": "^10.2.19", - "@storybook/addon-onboarding": "^10.2.19", - "@storybook/addon-vitest": "^10.2.19", - "@storybook/react-vite": "^10.2.19", + "@storybook/addon-a11y": "^10.5.5", + "@storybook/addon-docs": "^10.5.5", + "@storybook/addon-onboarding": "^10.5.5", + "@storybook/addon-vitest": "^10.5.5", + "@storybook/react-vite": "^10.5.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -81,23 +81,23 @@ "@types/react-dom": "^19.2.3", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "^4.1.0", - "@vitest/coverage-v8": "^4.1.0", + "@vitest/browser-playwright": "^4.1.10", + "@vitest/coverage-v8": "^4.1.10", "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-hooks": "~7.0.1", "eslint-plugin-react-refresh": "^0.5.2", - "eslint-plugin-storybook": "^10.2.19", + "eslint-plugin-storybook": "^10.5.5", "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", "prettier": "3.8.4", - "storybook": "^10.2.19", + "storybook": "^10.5.5", "tsup": "^8.5.1", "typescript": "~5.9.3", "typescript-eslint": "^8.56.1", "vite": "^8.1.5", - "vitest": "^4.1.0", + "vitest": "^4.1.10", "yaml": "^2.9.0" } } From 834fd2d77ddfdf68cfeecdd9d32d7f9d233bdf24 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 20:13:30 -0400 Subject: [PATCH 018/941] chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): migrate to eslint 10 to clear the brace-expansion DoS advisory (#1838) eslint 9 pulled `minimatch@3` → `brace-expansion@1.1.16`, which carries GHSA-mh99-v99m-4gvg (high, DoS via unbounded expansion length). It appeared in all five lockfiles — the root plus each of the four clients, none of which is an npm workspace, so all five had to move together. Deferred from the pre-2.0.0 sweep because it is dev-scope, not attacker- controlled, and a major bump across five packages was the wrong thing to attempt immediately before an irreversible publish. This is that work. ## What moved - `eslint` `^9.39.4/5` → `^10.8.0` and `@eslint/js` → `^10.0.1`, in all five manifests. - `eslint-plugin-react-hooks` `^7.0.1` → `^7.1.1` in web and tui. Required, not cosmetic: 7.0.1's peer range stops at `^9.0.0`, so installing it alongside eslint 10 fails `ERESOLVE`. - Everything else already declared eslint 10 support and stayed put: `typescript-eslint@^8.56.1` (`^8.57 || ^9 || ^10`), `eslint-plugin-react-refresh@^0.5.2` (`^9 || ^10`), `eslint-plugin-storybook@^10.2.19` (`>=8`). ## Config migration None was needed. All five configs were already flat (`eslint.config.js` with `defineConfig`/`globalIgnores` from `eslint/config`), which is the format v10 keeps and the only one it still supports. The root config's `core/` and shared- surface blocks — including the `_`-prefix `argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern` — are unchanged and still gate `lint:core` and `lint:shared`. No rule was disabled, downgraded, or scoped away to make the new eslint pass. v10 surfaced 21 real violations across three rules; all 21 are fixed in the code. ### `preserve-caught-error` (new in `eslint:recommended`) — 15 sites A `throw new Error(msg)` inside a `catch` silently drops the original error. Each now passes `{ cause }`, so the underlying failure survives into the chain. Messages are unchanged, so nothing that asserts on them moves. 12 in `core/`, 2 in `clients/cli`, 1 in `test-servers/src`. ### `no-useless-assignment` (new in `eslint:recommended`) — 2 sites `clients/cli/src/cli.ts`'s `optionArgs` and `test-servers/src/test-server-oauth` 's `valid` were initialized and then unconditionally overwritten on every path. Both are now declared without the dead initializer; TypeScript's definite- assignment analysis covers the remaining paths. ### `react-hooks/set-state-in-effect` — 8 sites (web) These were false negatives the plugin fixed in 7.1.0, not a rule we newly opted into: `set-state-in-effect` has been `error` in `flat/recommended` since 7.0.1, and the web config has always extended it. Every one was the same anti-pattern — resetting or re-syncing local state from a prop inside a `useEffect`, which paints the stale value for one frame and then renders again to correct it. All eight now use a new `useValueChange(value, onChange)` hook (`clients/web/src/hooks/useValueChange.ts`), which is React's documented "adjusting state during render": compare against the previous render's value with `Object.is` and call back *during* render, so React discards the in-progress output before it reaches the DOM. - `MrtrConversation`, `ProtocolEntry`, `TaskCard` — mirror `isListExpanded`. - `NetworkEntry` — same mirror, plus the "Reveal in Network" force-open. The reveal's rAF scroll stays an effect (real external-system work); only the `setIsExpanded(true)` moved. `isExpanded` is now seeded from `isListExpanded || revealed`, since a render-time sync fires on change and so cannot cover an entry that mounts already revealed. - `PromptArgumentsForm`, `ResourceTemplatePanel` — reset on template/prompt switch. - `ServerConfigModal` — reset on open. Keyed on `opened ? initial : undefined`, which collapses "opened flipped" and "initial changed while open" into one value; `initial` is already memoized, so it cannot thrash. `useValueChange` has its own test file, and `ServerConfigModal` gains an open→edit→close→reopen test (the close leg was previously untested, which is what left the new guard uncovered). ## Advisory delta `npm audit`, per package, before → after: | Package | before | after | | ------------------- | -------------------------- | -------------------------- | | root | 9 (1 low, 6 mod, 2 high) | 8 (1 low, 6 mod, 1 high) | | `clients/web` | 19 (3 low, 6 mod, 6 high, 4 crit) | 17 (3 low, 6 mod, 4 high, 4 crit) | | `clients/cli` | 6 (2 low, 4 high) | 4 (2 low, 2 high) | | `clients/tui` | 4 (1 low, 3 high) | 2 (1 low, 1 high) | | `clients/launcher` | 3 (3 high) | 1 (1 high) | `brace-expansion` is gone from all five — `eslint@10` resolves `minimatch@10` → `brace-expansion@5.0.9`. Dropping `@eslint/eslintrc` (removed in v10) also took `js-yaml` with it, clearing GHSA-52cp-r559-cp3m (high) from all four clients as a bonus. The remainder are unrelated and tracked separately. `npm run ci` passes end to end — validate, the per-file ≥90 coverage gate, the build gate, all five smokes (including `smoke:tui`, run locally on a real TTY), and 462 Storybook tests. Also documents the effect rule in AGENTS.md and mirrors it into `.github/copilot-instructions.md`, since it is now something a reviewer cites. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: state useValueChange's referential-stability requirement (#1838) Copilot review on #1900: the hook compares with `Object.is`, so an object or array literal rebuilt in the component body compares unequal every render. Because the callback is what updates state, that is not merely a wasted render — it is an infinite loop. Every current caller passes a primitive (`boolean`, `string`) or an already- memoized value, so nothing changes behaviorally. But the hazard was implicit, and the next caller is the one it would bite. Documented on the hook itself, and one clause added to the AGENTS.md rule with its mirror in `.github/copilot-instructions.md`. No runtime guard: the requirement is the same one a `useEffect` dependency array carries, and here the failure is loud and immediate rather than silent. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): stop asserting the --tool-args-json catch binding is an Error (#1838) Copilot review round 2 on #1900: the `--tool-args-json` parse handler formatted its message with `(e as Error).message`. A `catch` binding is `unknown`, so that cast is an assertion rather than a check — a thrown non-Error would render `--tool-args-json is not valid JSON: undefined`, which points the user at nothing. `JSON.parse` does only throw `SyntaxError` today, so this is not a live bug. But the cast is the kind AGENTS.md asks us not to leave unjustified, the same handler was already being touched here to add `{ cause: e }`, and every other site in this PR uses the guarded form. Now it does too. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: require useValueChange's onChange to be render-pure (#1838) Copilot review round 3 on #1900: `onChange` is called during render, so it is bound by render-phase purity — and the web client mounts under `StrictMode`, which deliberately double-renders in development. A callback doing anything external would run an unpredictable number of times, and concurrent React can abandon an in-progress render entirely. Every current caller already passes a callback that only calls `setState`, so nothing changes. But "runs during render" is the whole point of the hook and the constraint that follows from it was left implicit. Stated on the hook, with the same clause in the AGENTS.md rule and its `.github/copilot-instructions.md` mirror. All three now point at `NetworkEntry` as the worked example of the split: the reveal's force-open is a state update and lives in `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`. `npm run ci` passes. Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/copilot-instructions.md | 7 + AGENTS.md | 5 + clients/cli/package-lock.json | 451 ++++------------ clients/cli/package.json | 4 +- clients/cli/src/cli.ts | 5 +- clients/cli/src/handlers/run-method.ts | 1 + clients/launcher/package-lock.json | 393 +++++--------- clients/launcher/package.json | 4 +- clients/tui/package-lock.json | 453 ++++------------- clients/tui/package.json | 6 +- clients/web/package-lock.json | 450 ++++------------ clients/web/package.json | 6 +- .../MrtrConversation/MrtrConversation.tsx | 9 +- .../groups/NetworkEntry/NetworkEntry.tsx | 30 +- .../PromptArgumentsForm.tsx | 5 +- .../groups/ProtocolEntry/ProtocolEntry.tsx | 10 +- .../ResourceTemplatePanel.tsx | 9 +- .../ServerConfigModal.test.tsx | 22 + .../ServerConfigModal/ServerConfigModal.tsx | 22 +- .../components/groups/TaskCard/TaskCard.tsx | 9 +- clients/web/src/hooks/useValueChange.test.tsx | 89 ++++ clients/web/src/hooks/useValueChange.ts | 44 ++ core/auth/discovery.ts | 4 +- core/auth/node/runner-oauth-callback.ts | 2 +- core/auth/utils.ts | 4 +- core/mcp/import/clientConfig.ts | 2 +- core/mcp/import/serverJson.ts | 2 +- core/mcp/inspectorClient.ts | 5 + core/mcp/node/config.ts | 8 +- package-lock.json | 480 ++++-------------- package.json | 4 +- test-servers/src/load-config.ts | 4 +- test-servers/src/test-server-oauth.ts | 2 +- 33 files changed, 803 insertions(+), 1748 deletions(-) create mode 100644 clients/web/src/hooks/useValueChange.test.tsx create mode 100644 clients/web/src/hooks/useValueChange.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 092a0c6b..aa1d6dd1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -48,6 +48,13 @@ const CardContent = Group.withProps({ - **`data-*` attributes** — not part of a component's typed props, so excess-property-checked out of a `withProps` literal. Pass at the call site. - **Anything that isn't a Mantine factory component** — a `react-icons` glyph, another library's component, or a first-party plain `export function`. +### State and effects + +- **Never reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` paints the stale value first and renders twice; it is an error under `react-hooks/set-state-in-effect`. +- Use **`useValueChange(value, onChange)`** (`src/hooks/useValueChange.ts`) — React's documented "adjusting state during render" pattern. It does not fire on the first render; seed the state with `useState`. The comparison is `Object.is`, so pass a **referentially stable** value — a primitive key (id/name/URI) or a memoized one, never a fresh object literal. +- The `onChange` runs **during render**, so it must be pure — `setState` and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks; a render can be replayed or abandoned. +- Effects remain correct for real external-system synchronization (DOM measurement, rAF, subscriptions, timers). + ### Theme files vs. element components Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) customize a Mantine primitive **app-wide**. Element components (`src/components/elements/`) add **domain semantics** on top of primitives. diff --git a/AGENTS.md b/AGENTS.md index c67a38ad..b86db380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -541,6 +541,11 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` // Component.tsx const MessageContainer = Paper.withProps({ variant: 'message' }); ``` +- State and effects + - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces. + - Use **`useValueChange(value, onChange)`** (`clients/web/src/hooks/useValueChange.ts`) instead. It is React's documented ["adjusting state during render"](https://react.dev/reference/react/useState#storing-information-from-previous-renders) pattern: it compares `value` against the previous render's with `Object.is` and calls `onChange(next)` during render, so React discards the in-progress output and re-runs the component before anything reaches the DOM. It does **not** fire on the first render — seed the dependent state with `useState` instead. Because the comparison is `Object.is`, the value you pass **must be referentially stable** across renders that mean "no change": prefer a primitive key derived from the data (an id, a name, a URI), and otherwise a memoized value. A fresh object/array literal would compare unequal every render and loop. + - The `onChange` you pass runs **during render**, so it must be pure — `setState` calls and nothing else. No fetches, DOM writes, logging, ref mutation, or parent callbacks: a render can be replayed (StrictMode) or abandoned (concurrent React), so external work would run an unpredictable number of times. + - An effect is still the right tool for genuine synchronization with an external system (DOM measurement, `requestAnimationFrame`, subscriptions, timers). The rule is about deriving React state from React props, not about effects in general. `NetworkEntry` shows the split: the reveal's force-open is a state update and uses `useValueChange`, while its `requestAnimationFrame` scroll stays a `useEffect`. - Theme files vs. Storybook element components - **Theme files** (`src/theme/<Component>.ts`) and **element components** (`src/components/elements/`) serve different purposes and both are needed. - Theme files customize every instance of a Mantine component app-wide — defaults (size, radius), custom variants, and global style overrides. They are applied automatically by `MantineProvider`. diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 9018de13..ee8ea1e4 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -25,11 +25,11 @@ "mcp-inspector-cli": "build/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "globals": "^17.4.0", "prettier": "3.8.4", "tsup": "^8.5.0", @@ -617,142 +617,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -1873,6 +1820,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2152,45 +2106,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", @@ -2233,19 +2148,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -2443,22 +2345,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2466,13 +2352,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2515,11 +2394,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/body-parser": { "version": "2.2.2", @@ -2547,14 +2429,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "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": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/bundle-name": { @@ -2638,16 +2522,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2658,23 +2532,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2691,26 +2548,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -2720,13 +2557,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3043,33 +2873,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3079,8 +2909,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3088,7 +2917,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -3103,30 +2932,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3157,18 +2988,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3684,33 +3515,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3895,29 +3699,6 @@ "dev": true, "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4281,13 +4062,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4387,16 +4161,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mlly": { @@ -4602,19 +4379,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5331,19 +5095,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index c352af6c..598fc7f3 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -46,11 +46,11 @@ "zod": "^4.3.6" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "globals": "^17.4.0", "prettier": "3.8.4", "tsup": "^8.5.0", diff --git a/clients/cli/src/cli.ts b/clients/cli/src/cli.ts index 87dbceb1..f4d418ae 100644 --- a/clients/cli/src/cli.ts +++ b/clients/cli/src/cli.ts @@ -535,7 +535,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> { const scriptArgs = rawArgs.slice(2); const dashDashIndex = scriptArgs.indexOf("--"); let targetArgs: string[] = []; - let optionArgs: string[] = []; + let optionArgs: string[]; if (dashDashIndex >= 0) { targetArgs = scriptArgs.slice(0, dashDashIndex); optionArgs = scriptArgs.slice(dashDashIndex + 1); @@ -977,7 +977,8 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> { parsed = JSON.parse(options.toolArgsJson); } catch (e) { throw new Error( - `--tool-args-json is not valid JSON: ${(e as Error).message}`, + `--tool-args-json is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, ); } if ( diff --git a/clients/cli/src/handlers/run-method.ts b/clients/cli/src/handlers/run-method.ts index 9e50bab6..68b490ca 100644 --- a/clients/cli/src/handlers/run-method.ts +++ b/clients/cli/src/handlers/run-method.ts @@ -301,6 +301,7 @@ export async function runMethod( } catch (e) { throw new Error( `--roots-json is invalid: ${e instanceof Error ? e.message : String(e)}`, + { cause: e }, ); } await inspectorClient.setRoots(roots); diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index c66bfe68..93c1e23b 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -14,10 +14,10 @@ "mcp-inspector": "build/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "globals": "^17.4.0", "prettier": "3.8.4", "typescript": "~5.9.3", @@ -146,102 +146,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { - "version": "9.39.4", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -669,6 +656,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "dev": true, @@ -676,6 +670,8 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, @@ -857,39 +853,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.61.1", "dev": true, @@ -928,17 +891,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -1084,7 +1036,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1096,6 +1050,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1104,6 +1060,8 @@ }, "node_modules/ajv": { "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1117,25 +1075,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "dev": true, - "license": "Python-2.0" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1157,25 +1096,26 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/callsites": { - "version": "3.1.0", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "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": "20 || >=22" } }, "node_modules/chai": { @@ -1188,37 +1128,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "13.1.0", "license": "MIT", @@ -1226,11 +1135,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1301,31 +1205,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -1335,8 +1241,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -1344,7 +1249,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -1359,42 +1264,50 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -1413,6 +1326,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1458,11 +1373,15 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, @@ -1582,27 +1501,14 @@ }, "node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "dev": true, @@ -1673,27 +1579,6 @@ "dev": true, "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.2.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "dev": true, @@ -1701,6 +1586,8 @@ }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, @@ -2016,11 +1903,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2056,14 +1938,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -2153,17 +2040,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -2260,20 +2136,14 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rolldown": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", @@ -2367,17 +2237,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -2502,6 +2361,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { diff --git a/clients/launcher/package.json b/clients/launcher/package.json index 55ec6b47..1f1b110d 100644 --- a/clients/launcher/package.json +++ b/clients/launcher/package.json @@ -26,10 +26,10 @@ "commander": "^13.1.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", + "eslint": "^10.8.0", "globals": "^17.4.0", "prettier": "3.8.4", "typescript": "~5.9.3", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 2117a80c..0b3dbd3e 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -26,12 +26,12 @@ "mcp-inspector-tui": "build/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.4.0", "ink-testing-library": "^4.0.0", "prettier": "3.8.4", @@ -853,142 +853,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@humanfs/core": { @@ -2059,6 +2006,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2281,45 +2235,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.61.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", @@ -2362,19 +2277,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -2604,13 +2506,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2665,11 +2560,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.10.38", @@ -2685,14 +2583,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "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": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -2770,16 +2670,6 @@ "node": ">=8" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -2894,26 +2784,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -2923,13 +2793,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -3163,33 +3026,33 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -3199,8 +3062,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3208,7 +3070,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -3243,30 +3105,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3289,39 +3153,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3343,18 +3174,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -3686,33 +3517,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4056,29 +3860,6 @@ "dev": true, "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==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4468,13 +4249,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4533,16 +4307,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/mlly": { @@ -4729,19 +4506,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -5341,19 +5105,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", diff --git a/clients/tui/package.json b/clients/tui/package.json index e6849426..9a3278bc 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -45,12 +45,12 @@ "ink-select-input": "^6.2.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.4.0", "ink-testing-library": "^4.0.0", "prettier": "3.8.4", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 2a5154dc..22c54dd7 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@storybook/addon-a11y": "^10.5.5", "@storybook/addon-docs": "^10.5.5", "@storybook/addon-onboarding": "^10.5.5", @@ -60,8 +60,8 @@ "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "~7.0.1", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", "express": "^5.2.1", @@ -1074,142 +1074,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@floating-ui/core": { @@ -3836,6 +3783,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -4235,45 +4189,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "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": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", @@ -4316,19 +4231,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@ungap/structured-clone": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", @@ -4706,22 +4608,6 @@ "node": ">=8" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -4729,13 +4615,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4853,11 +4732,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/baseline-browser-mapping": { "version": "2.11.10", @@ -4910,14 +4792,16 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "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": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -5103,23 +4987,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -5225,26 +5092,6 @@ "node": ">=6" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -5264,13 +5111,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/confbox": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", @@ -5743,33 +5583,33 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -5779,8 +5619,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5788,7 +5627,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -5803,9 +5642,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { @@ -5819,7 +5658,7 @@ "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "node_modules/eslint-plugin-react-refresh": { @@ -5848,30 +5687,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -5902,18 +5743,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6398,45 +6239,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "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": "20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "17.8.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", @@ -6996,29 +6798,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "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": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7448,13 +7227,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -8481,16 +8253,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -10280,19 +10055,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index 4837502f..a347376e 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -65,7 +65,7 @@ }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", - "@eslint/js": "^9.39.4", + "@eslint/js": "^10.0.1", "@storybook/addon-a11y": "^10.5.5", "@storybook/addon-docs": "^10.5.5", "@storybook/addon-onboarding": "^10.5.5", @@ -83,8 +83,8 @@ "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "~7.0.1", + "eslint": "^10.8.0", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", "express": "^5.2.1", diff --git a/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx b/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx index 1f98e3dc..0ad17804 100644 --- a/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx +++ b/clients/web/src/components/groups/MrtrConversation/MrtrConversation.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Badge, Collapse, @@ -13,6 +13,7 @@ import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { extractMethod, extractResultType } from "../protocolUtils.js"; +import { useValueChange } from "../../../hooks/useValueChange"; export interface MrtrConversationProps { /** The opaque MRTR token that links this conversation's rounds. */ @@ -131,9 +132,9 @@ export function MrtrConversation({ }: MrtrConversationProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); - useEffect(() => { - setIsExpanded(isListExpanded); - }, [isListExpanded]); + // The list-level Expand/Collapse toggle is authoritative: any per-entry + // override is discarded whenever the parent changes `isListExpanded`. + useValueChange(isListExpanded, setIsExpanded); // Always read the conversation chronologically (original → retries → final), // regardless of the list's newest-first/oldest-first sort. diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index ab6ec117..7ef790bc 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -22,6 +22,7 @@ import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; import { CategoryBadge } from "../../elements/CategoryBadge/CategoryBadge"; import { maskSecretsInBody } from "../../../utils/maskSecrets"; +import { useValueChange } from "../../../hooks/useValueChange"; import { oauthNetworkPhase, oauthNetworkPhaseLabel, @@ -443,30 +444,35 @@ export function NetworkEntry({ revealed = false, onRevealComplete, }: NetworkEntryProps) { - const [isExpanded, setIsExpanded] = useState(isListExpanded); + // Seeded from both sources so an entry that mounts already targeted by + // "Reveal in Network" starts open — the render-time syncs below only fire on + // a *change*, so neither of them covers the first render. + const [isExpanded, setIsExpanded] = useState(isListExpanded || revealed); const rootRef = useRef<HTMLDivElement>(null); // The list-level Expand/Collapse toggle is authoritative: each time the // parent changes `isListExpanded`, every entry snaps to that state and // any per-entry override is intentionally discarded. Mirrors - // ProtocolEntry; do not change without aligning both. This re-runs on - // re-render when `isListExpanded` keeps its reference, but the setter - // is a no-op when the next value equals the current one. - useEffect(() => { - setIsExpanded(isListExpanded); - }, [isListExpanded]); + // ProtocolEntry; do not change without aligning both. + useValueChange(isListExpanded, setIsExpanded); - // "Reveal in Network" one-shot: when targeted, force this entry open and - // scroll it into view, then clear the signal. The scroll runs in a rAF so it - // lands after `useScrollMemory`'s layout-effect restore (which would otherwise - // fight it) and after the force-expand has grown the row. `onRevealComplete` + // "Reveal in Network" one-shot, part 1: force the targeted entry open. This + // is deliberately ordered *after* the list sync above, so that if both change + // in the same render the reveal wins. + useValueChange(revealed, (nextRevealed) => { + if (nextRevealed) setIsExpanded(true); + }); + + // "Reveal in Network" one-shot, part 2: scroll the entry into view, then + // clear the signal. The scroll runs in a rAF so it lands after + // `useScrollMemory`'s layout-effect restore (which would otherwise fight it) + // and after the force-expand above has grown the row. `onRevealComplete` // clears the parent's `revealId`, which flips `revealed` back to false and re- // runs this effect's cleanup — so it must fire *inside* the rAF, after the // scroll, otherwise the cleanup's `cancelAnimationFrame` would race and could // cancel the very frame doing the scroll. useEffect(() => { if (!revealed) return; - setIsExpanded(true); const raf = requestAnimationFrame(() => { rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); onRevealComplete?.(); diff --git a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx index 8f2fe05b..856eb488 100644 --- a/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx +++ b/clients/web/src/components/groups/PromptArgumentsForm/PromptArgumentsForm.tsx @@ -9,6 +9,7 @@ import { Title, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import type { Prompt } from "@modelcontextprotocol/client"; export interface PromptArgumentsFormProps { @@ -67,9 +68,9 @@ export function PromptArgumentsForm({ // Reset completion state whenever the active prompt changes — completions // are keyed by argument name, and the same name could mean different // things across prompts. - useEffect(() => { + useValueChange(name, () => { setCompletions({}); - }, [name]); + }); // Per-arg in-flight controller (later keystroke aborts older request). const requestsRef = useRef<Map<string, AbortController>>(new Map()); diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index d1111239..c7cb52f3 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Alert, Anchor, @@ -21,6 +21,7 @@ import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { PinToggle } from "../../elements/PinToggle/PinToggle"; import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import { classifyProtocolSpecError, type McpSpecError, @@ -296,9 +297,10 @@ export function ProtocolEntry({ const resultType = extractResultType(entry); const subscriptionId = extractSubscriptionId(entry); - useEffect(() => { - setIsExpanded(isListExpanded); - }, [isListExpanded]); + // The list-level Expand/Collapse toggle is authoritative: any per-entry + // override is discarded whenever the parent changes `isListExpanded`. + // Mirrors NetworkEntry; do not change without aligning both. + useValueChange(isListExpanded, setIsExpanded); const directionBadge = entry.origin && ( <MessageDirectionBadge diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index af4d9422..6b506228 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -10,6 +10,7 @@ import { } from "@mantine/core"; import { accessibleTextColor } from "../../elements/accessibleTextColor"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; @@ -117,11 +118,13 @@ export function ResourceTemplatePanel({ ); const [completions, setCompletions] = useState<Record<string, string[]>>({}); - // Reset state when the user switches to a different template. - useEffect(() => { + // Reset state when the user switches to a different template. Keyed on + // `uriTemplate` alone because `variableNames` is memoized from it, so the two + // can never change independently. + useValueChange(uriTemplate, () => { setVariables(Object.fromEntries(variableNames.map((n) => [n, ""]))); setCompletions({}); - }, [uriTemplate, variableNames]); + }); // Latest in-flight controller per argument, so a faster keystroke can // abort an outstanding completion request and the late response can't diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index 884a180b..2f6f4e40 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -441,6 +441,28 @@ describe("ServerConfigModal", () => { expect(props.onSubmit).not.toHaveBeenCalled(); }); + it("discards in-progress edits when reopened, and leaves them alone while closed", async () => { + const user = userEvent.setup({ delay: null }); + const props = base(); + const { rerender } = renderWithMantine( + <ServerConfigModal {...props} opened={false} />, + ); + + // Open: the form is seeded from `initial`. + rerender(<ServerConfigModal {...props} opened />); + await user.type(screen.getByLabelText(/Server ID/i), "alpha"); + expect(screen.getByLabelText(/Server ID/i)).toHaveValue("alpha"); + + // Close: nothing is reset while the modal is closed (it is unmounted, so + // there is nothing to observe) — this exercises the closed-path guard. + rerender(<ServerConfigModal {...props} opened={false} />); + expect(screen.queryByText("Add server")).not.toBeInTheDocument(); + + // Reopen: the abandoned "alpha" is gone. + rerender(<ServerConfigModal {...props} opened />); + expect(screen.getByLabelText(/Server ID/i)).toHaveValue(""); + }); + it("calls onClose when Cancel is clicked", async () => { const user = userEvent.setup({ delay: null }); const props = base(); diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index a45a3b3c..e1e823a6 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ChangeEvent } from "react"; +import { useMemo, useState, type ChangeEvent } from "react"; import { Button, Group, @@ -10,6 +10,7 @@ import { Textarea, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import type { MCPServerConfig, StdioServerConfig, @@ -209,14 +210,17 @@ export function ServerConfigModal({ const clearTextField = (field: TextField) => () => setForm((f) => ({ ...f, [field]: "" })); - // Reset form whenever the modal opens with new inputs. - useEffect(() => { - if (opened) { - setForm(initial); - setSubmitError(undefined); - setSubmitting(false); - } - }, [opened, initial]); + // Reset the form whenever the modal opens, or whenever `initial` changes + // while it is open. Keying on `opened ? initial : undefined` collapses both + // triggers into one value: it flips to `initial` on open, tracks `initial` + // while open, and flips to `undefined` on close (where the guard below makes + // the reset a no-op, matching the previous effect's `if (opened)`). + useValueChange(opened ? initial : undefined, () => { + if (!opened) return; + setForm(initial); + setSubmitError(undefined); + setSubmitting(false); + }); const trimmedId = form.id.trim(); const idIsValid = ID_PATTERN.test(trimmedId); diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.tsx index 8946e904..4d932e4e 100644 --- a/clients/web/src/components/groups/TaskCard/TaskCard.tsx +++ b/clients/web/src/components/groups/TaskCard/TaskCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button, Card, @@ -13,6 +13,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; import { TaskStatusBadge } from "../../elements/TaskStatusBadge/TaskStatusBadge"; +import { useValueChange } from "../../../hooks/useValueChange"; export type TaskProgress = Pick< ProgressNotification["params"], @@ -116,9 +117,9 @@ export function TaskCard({ const [isExpanded, setIsExpanded] = useState(isListExpanded); const isActive = status === "working" || status === "input_required"; - useEffect(() => { - setIsExpanded(isListExpanded); - }, [isListExpanded]); + // The list-level Expand/Collapse toggle is authoritative: any per-card + // override is discarded whenever the parent changes `isListExpanded`. + useValueChange(isListExpanded, setIsExpanded); return ( <TaskContainer> diff --git a/clients/web/src/hooks/useValueChange.test.tsx b/clients/web/src/hooks/useValueChange.test.tsx new file mode 100644 index 00000000..558c7fd3 --- /dev/null +++ b/clients/web/src/hooks/useValueChange.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { renderWithMantine } from "../test/renderWithMantine"; +import { useValueChange } from "./useValueChange"; + +/** + * Mirrors the real usage: `mirrored` is seeded from `source`, can be overridden + * locally, and snaps back whenever `source` changes. + */ +function Probe({ + source, + onChange, +}: { + source: number; + onChange?: (next: number) => void; +}) { + const [mirrored, setMirrored] = useState(source); + const [renders, setRenders] = useState(0); + useValueChange(source, (next) => { + setMirrored(next); + onChange?.(next); + }); + return ( + <> + <span data-testid="mirrored">{mirrored}</span> + <span data-testid="renders">{renders}</span> + <button onClick={() => setMirrored(-1)}>override</button> + <button onClick={() => setRenders((r) => r + 1)}>rerender</button> + </> + ); +} + +describe("useValueChange", () => { + it("does not call onChange on the first render", () => { + const onChange = vi.fn(); + const { getByTestId } = renderWithMantine( + <Probe source={5} onChange={onChange} />, + ); + expect(getByTestId("mirrored").textContent).toBe("5"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("calls onChange with the new value when the watched value changes", () => { + const onChange = vi.fn(); + const { rerender, getByTestId } = renderWithMantine( + <Probe source={5} onChange={onChange} />, + ); + rerender(<Probe source={7} onChange={onChange} />); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(7); + expect(getByTestId("mirrored").textContent).toBe("7"); + }); + + it("discards a local override when the watched value changes", async () => { + const user = userEvent.setup(); + const { rerender, getByTestId, getByText } = renderWithMantine( + <Probe source={5} />, + ); + await user.click(getByText("override")); + expect(getByTestId("mirrored").textContent).toBe("-1"); + + rerender(<Probe source={9} />); + expect(getByTestId("mirrored").textContent).toBe("9"); + }); + + it("keeps a local override across re-renders that leave the value unchanged", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + const { getByTestId, getByText } = renderWithMantine( + <Probe source={5} onChange={onChange} />, + ); + await user.click(getByText("override")); + await user.click(getByText("rerender")); + + expect(getByTestId("renders").textContent).toBe("1"); + expect(getByTestId("mirrored").textContent).toBe("-1"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("treats NaN as unchanged (Object.is, not ===)", () => { + const onChange = vi.fn(); + const { rerender } = renderWithMantine( + <Probe source={NaN} onChange={onChange} />, + ); + rerender(<Probe source={NaN} onChange={onChange} />); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/hooks/useValueChange.ts b/clients/web/src/hooks/useValueChange.ts new file mode 100644 index 00000000..6346dfee --- /dev/null +++ b/clients/web/src/hooks/useValueChange.ts @@ -0,0 +1,44 @@ +import { useState } from "react"; + +/** + * Call `onChange(next)` during render whenever `value` differs from the value + * seen on the previous render of this component. Nothing is called on the first + * render — seed the dependent state with `useState` instead. + * + * This is React's documented "adjusting state during render" pattern + * (https://react.dev/reference/react/useState#storing-information-from-previous-renders), + * and it is the supported way to reset or re-sync local state from a prop. + * + * ⚠️ **`onChange` runs during render, so it must be pure** — `setState` calls + * and nothing else. No fetches, no DOM writes, no logging, no ref mutation, no + * parent callbacks. A render can be replayed or thrown away (StrictMode + * double-renders in development; concurrent React can abandon an in-progress + * render at any time), so anything external would run an unpredictable number + * of times. Real external work belongs in a `useEffect`, which is exactly the + * split `NetworkEntry` uses: the reveal's force-open is a state update and + * lives here, while its `requestAnimationFrame` scroll stays an effect. + * + * The obvious-looking alternative — `useEffect(() => setX(prop), [prop])` — is + * worse and is reported by `react-hooks/set-state-in-effect`: the effect only + * runs *after* the component has already painted with the stale value, so the + * user sees one frame of the old state and React has to render twice. Adjusting + * during render lets React discard the in-progress output and re-run the + * component body before anything reaches the DOM. + * + * ⚠️ **`value` must be referentially stable across renders that mean "no + * change".** The comparison is `Object.is`, so an object or array literal built + * fresh in the component body looks different on every render — `onChange` + * would fire every render, and because it is what updates state, that is an + * infinite render loop rather than a merely wasteful one. Pass a **primitive + * key** derived from the data (an id, a name, a URI) wherever one exists, and + * otherwise a value that is already memoized or owned by the parent. This is + * the same stability requirement a `useEffect` dependency array carries; the + * difference is only that here the failure is loud and immediate. + */ +export function useValueChange<T>(value: T, onChange: (next: T) => void): void { + const [previous, setPrevious] = useState(value); + if (!Object.is(previous, value)) { + setPrevious(value); + onChange(value); + } +} diff --git a/core/auth/discovery.ts b/core/auth/discovery.ts index 5109cd23..b64ecab9 100644 --- a/core/auth/discovery.ts +++ b/core/auth/discovery.ts @@ -19,7 +19,9 @@ export function getAuthorizationServerUrl( return new URL("/", serverUrl); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid MCP server URL: "${serverUrl}" (${detail})`); + throw new Error(`Invalid MCP server URL: "${serverUrl}" (${detail})`, { + cause: err, + }); } } diff --git a/core/auth/node/runner-oauth-callback.ts b/core/auth/node/runner-oauth-callback.ts index 61affb70..c6a8b96f 100644 --- a/core/auth/node/runner-oauth-callback.ts +++ b/core/auth/node/runner-oauth-callback.ts @@ -53,7 +53,7 @@ export function parseRunnerOAuthCallbackUrl( } catch (err) { /* v8 ignore next -- new URL() only throws an Error with a message; the String(err) fallback is unreachable */ const reason = (err as Error)?.message ?? String(err); - throw new Error(`Invalid OAuth callback URL: ${reason}`); + throw new Error(`Invalid OAuth callback URL: ${reason}`, { cause: err }); } if (url.protocol !== "http:") { throw new Error("OAuth callback URL must use http scheme"); diff --git a/core/auth/utils.ts b/core/auth/utils.ts index fd6ea445..e4f587ab 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -76,7 +76,9 @@ export function parseHttpUrl(value: string, label: string): URL { return new URL(trimmed); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`); + throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`, { + cause: err, + }); } } diff --git a/core/mcp/import/clientConfig.ts b/core/mcp/import/clientConfig.ts index 20c9818f..49e2c8ec 100644 --- a/core/mcp/import/clientConfig.ts +++ b/core/mcp/import/clientConfig.ts @@ -22,7 +22,7 @@ function parseJsonObject(raw: string): Record<string, unknown> { data = JSON.parse(raw); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON: ${detail}`); + throw new Error(`Invalid JSON: ${detail}`, { cause: err }); } if (!data || typeof data !== "object" || Array.isArray(data)) { throw new Error("Expected a JSON object at the top level"); diff --git a/core/mcp/import/serverJson.ts b/core/mcp/import/serverJson.ts index a076b99c..aaa8719e 100644 --- a/core/mcp/import/serverJson.ts +++ b/core/mcp/import/serverJson.ts @@ -297,7 +297,7 @@ export function parseServerJson(raw: string): ParsedServerJson { data = JSON.parse(raw); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid JSON: ${detail}`); + throw new Error(`Invalid JSON: ${detail}`, { cause: err }); } const top = asObject(data); if (!top) throw new Error("Expected a JSON object at the top level"); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b53beb84..e63b1473 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4160,6 +4160,7 @@ export class InspectorClient extends InspectorClientEventTarget { } catch (resultError) { throw new Error( `Tool call did not return a result: ${resultError instanceof Error ? resultError.message : String(resultError)}`, + { cause: resultError }, ); } } @@ -4337,6 +4338,7 @@ export class InspectorClient extends InspectorClientEventTarget { } catch (error) { throw new Error( `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } @@ -4597,6 +4599,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Re-throw other errors throw new Error( `Failed to get completions: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } @@ -5146,6 +5149,7 @@ export class InspectorClient extends InspectorClientEventTarget { } catch (error) { throw new Error( `Failed to subscribe to resource: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } @@ -5206,6 +5210,7 @@ export class InspectorClient extends InspectorClientEventTarget { } catch (error) { throw new Error( `Failed to unsubscribe from resource: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, ); } } diff --git a/core/mcp/node/config.ts b/core/mcp/node/config.ts index 9908eb36..c90a9d6e 100644 --- a/core/mcp/node/config.ts +++ b/core/mcp/node/config.ts @@ -146,9 +146,13 @@ function loadMcpServersConfig( return { ...config, mcpServers: normalizedServers }; } catch (error) { if (error instanceof Error) { - throw new Error(`Error loading configuration: ${error.message}`); + throw new Error(`Error loading configuration: ${error.message}`, { + cause: error, + }); } - throw new Error("Error loading configuration: Unknown error"); + throw new Error("Error loading configuration: Unknown error", { + cause: error, + }); } } diff --git a/package-lock.json b/package-lock.json index 96b4f42e..58f21e7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,8 +38,8 @@ "mcp-inspector": "clients/launcher/build/index.js" }, "devDependencies": { - "@eslint/js": "^9.39.5", - "eslint": "^9.39.5", + "@eslint/js": "^10.0.1", + "eslint": "^10.8.0", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", @@ -136,142 +136,89 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.5" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", - "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.0", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", - "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@hono/node-server": { @@ -1039,6 +986,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1241,45 +1195,6 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", @@ -1322,19 +1237,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@vitejs/plugin-react": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", @@ -1375,9 +1277,9 @@ } }, "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -1470,13 +1372,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -1509,11 +1404,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/body-parser": { "version": "2.2.2", @@ -1541,14 +1439,16 @@ } }, "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": "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": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/bundle-name": { @@ -1606,16 +1506,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -1698,26 +1588,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, "node_modules/commander": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", @@ -1727,13 +1597,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -2009,33 +1872,33 @@ } }, "node_modules/eslint": { - "version": "9.39.5", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", - "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.6", - "@eslint/js": "9.39.5", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", - "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -2045,8 +1908,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2054,7 +1916,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -2069,30 +1931,32 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2115,39 +1979,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2169,18 +2000,18 @@ "license": "MIT" }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -2600,16 +2431,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2691,23 +2512,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3009,29 +2813,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -3360,13 +3141,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3437,16 +3211,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -3627,19 +3404,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3949,16 +3713,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -4322,19 +4076,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -4350,19 +4091,6 @@ "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", "license": "MIT" }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", diff --git a/package.json b/package.json index 2adc0d5f..d9472940 100644 --- a/package.json +++ b/package.json @@ -105,8 +105,8 @@ "node": ">=22.19.0" }, "devDependencies": { - "@eslint/js": "^9.39.5", - "eslint": "^9.39.5", + "@eslint/js": "^10.0.1", + "eslint": "^10.8.0", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 760363cb..8e225200 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -110,7 +110,9 @@ function parseContent( return YAML.parse(content); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to parse config file ${filePath}: ${msg}`); + throw new Error(`Failed to parse config file ${filePath}: ${msg}`, { + cause: err, + }); } } diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 4566f5f1..79200366 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -91,7 +91,7 @@ export function createBearerTokenMiddleware( const token = authHeader.substring(7); // Remove "Bearer " prefix - let valid = false; + let valid: boolean; let grantedScopes: string[] = []; if (mode === "protected-resource") { try { From 07a2b4bdfda06087cbdf8863d990a0c32f8009c3 Mon Sep 17 00:00:00 2001 From: Cliff Hall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 23:36:46 -0400 Subject: [PATCH 019/941] ci: sync the release workflow on v2/main with main's verbatim (#1902) (#1904) main's copy of .github/workflows/main.yml carried three release fixes v2/main never took -- #1831 (least-privilege default GITHUB_TOKEN scope), #1834 (derive the npm dist-tag from the version) and #1836 (publish via npm OIDC trusted publishing). Diffing the file both directions shows v2/main has NOTHING main lacks: its only unique content is the superseded publish step, run: npm publish --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} which is exactly what #1836 replaced. Every other line already matched. So this is a verbatim copy of main's file rather than a hunk-by-hunk forward-port -- fewer ways to get it wrong, and it leaves the two branches byte-identical (`git diff origin/main -- .github/workflows` is now empty). Not a back-merge: only this one file is taken, so none of main's pre-swap v1 lineage enters v2/main's ancestry (see #1868). Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/main.yml | 41 +++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5fdc748d..71e9ec4c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -8,6 +8,15 @@ on: release: types: [published] +# Default least-privilege scope for GITHUB_TOKEN. Without this, jobs inherit the +# repository's default token permissions, which are broader than any job here +# needs (CodeQL `actions/missing-workflow-permissions`). The `publish` and +# `publish-github-container-registry` jobs declare their own blocks below, which +# override this one entirely rather than adding to it — so each publish job must +# continue to list every scope it needs, including `contents: read`. +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest @@ -145,6 +154,11 @@ jobs: exit 1 fi + # OIDC trusted publishing requires npm >= 11.5.1; Node 22's bundled npm is + # 10.x, which fails with ENEEDAUTH before OIDC is ever attempted. + - name: Ensure npm CLI supports OIDC trusted publishing + run: npm install -g npm@^11.5.1 + - name: Install dependencies (root + all clients) run: npm install @@ -160,9 +174,30 @@ jobs: # prepack); the redundancy is intentional — each is a clean-tree rebuild # and the `prepack` one is what actually populates the published tarball, # so don't "optimize" it away. - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # + # The dist-tag is derived from the version, and passing it explicitly is + # NOT optional: `npm publish` defaults to `--tag latest` regardless of + # semver prerelease status, so publishing `2.0.0-rc.1` without this would + # point every `npx @modelcontextprotocol/inspector` at a release + # candidate. A prerelease is a hyphen after the patch component + # (`2.0.0-rc.1`); build metadata uses `+` and is not a prerelease. Done + # in shell rather than with `semver` because that package is only a + # transitive dependency here and must not be relied on in CI. + # + # There is deliberately NO `NODE_AUTH_TOKEN` here. Publishing uses npm + # OIDC trusted publishing (`id-token: write` + `environment: release`), + # which needs no token — and the repo has no `NPM_TOKEN` secret. Setting + # it from a non-existent secret writes an EMPTY `_authToken` into the + # `.npmrc` that `setup-node` generates, and npm then fails `ENEEDAUTH` + # before OIDC is ever attempted. Do not "restore" it. + run: | + VERSION="$(node -p "require('./package.json').version")" + case "$VERSION" in + *-*) NPM_TAG=next ;; + *) NPM_TAG=latest ;; + esac + echo "Publishing $VERSION under dist-tag '$NPM_TAG'" + npm publish --access public --provenance --tag "$NPM_TAG" # Build and push the multi-arch container image to GHCR on a published # release. The image installs the packed tarball (`Dockerfile`) so it ships From dedee5afb5c91a9dff10fb383df8e0db214f33d1 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 1 Aug 2026 23:52:03 -0400 Subject: [PATCH 020/941] chore: 2.1.0 Bumps the root package.json (and its lockfile entries) 2.0.0 -> 2.1.0 for the v2.1.0 milestone release. Only the ROOT manifest carries a version -- the four clients/* packages deliberately have none, since the Inspector ships as one tarball with a single version number that every Node client resolves through readInspectorVersion() in core/node/version.ts. So this is the only file that needs changing. Done with `npm version 2.1.0 --no-git-tag-version`: the tag is NOT created here. #1903 merges as a merge commit, so this commit lands on main intact and the release tag is pushed against it after the merge. Creating the tag now would point it at a commit that is not yet on the release branch. The publish job asserts the release tag matches this version. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58f21e7a..30b8f2cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.1.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index d9472940..2191a019 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.1.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 943f37a40a861d099952b7f95739815057f72e1d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 14:37:31 -0400 Subject: [PATCH 021/941] docs: make assigning a milestone the act that moves a card to Todo AGENTS.md had no rule linking a milestone to a board Status, and its two existing rules contradicted each other: every new issue was to be milestoned at create time, yet new items were to start in Incoming, with Todo reserved for work a maintainer had approved. If every issue is milestoned at birth, "has a milestone" carries no approval information and nothing records the moment of sign-off. Make assigning a milestone the approval act, giving one invariant: Incoming <=> no milestone; everything past it <=> milestoned. External reports and maintainer-filed issues then differ only in whether approval has already happened -- an outside reporter cannot set a label, milestone, or board field, so their issue arriving with none of them is normal rather than a defect to fix on arrival. - scope the "not created until five things" callout to maintainer-filed issues - split the Status bullets into the two cases - scope the milestone mandate to issues you create, stating the external exception explicitly - add a "Triaging externally-filed issues" section: the two passes, plus a gh/jq snippet that diffs open issues against the board to find unboarded ones - reword both prose descriptions of the Incoming/Todo line - reword the rubric's "Linked to a milestone" bonus as "already approved" and note it is the re-scoring case only; under the old rules it was a constant offset on every issue, shifting every band boundary down by one - update both gh recipes (#28 and #11) to default to Todo, with an Incoming variant for triage No mirror to .github/copilot-instructions.md: board and milestone mechanics are on that file's deliberately-absent list. Closes #1930 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 67 ++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b86db380..4eb96861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,9 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue *you* create is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> +> **This describes an issue a maintainer files, not one an outside reporter files.** An external reporter can't set a label, a milestone, or a board field, so their issue arrives with none of them — that is normal and not a defect to fix at the moment it lands. It is brought into the system by [triage](#triaging-externally-filed-issues) instead. The two paths differ in exactly one thing: filing an issue yourself *is* approving it, so it starts in **Todo** with a milestone; an external report has not been approved by anyone yet, so it starts in **Incoming** with no milestone. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. @@ -156,11 +158,12 @@ All work should be driven by items on the project board. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). -- **Prioritize every new issue.** Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. +- **Prioritize every board item.** Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an external report). Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - - **New and untriaged → `Incoming`.** This is the **default status for a new item on either board.** An issue nobody has evaluated yet belongs in **Incoming**, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. + - **An issue you create → `Todo`.** You only file an issue for work you intend to happen, so filing it is approving it. It gets a milestone and lands in Todo, ready to be picked up. Work you are starting immediately goes straight to **In Progress** instead. + - **An externally-filed issue → `Incoming`, no milestone.** Nobody has evaluated it yet, so it is not approved and gets no release bucket. See [Triaging externally-filed issues](#triaging-externally-filed-issues). Never park an unreviewed report in Todo — Todo asserts a maintainer signed off, and using it as the inbox erases that distinction and quietly promotes unreviewed work into the queue. - **Priority is v2-only.** Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else. -- **Every new issue gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. Get the current milestone with: +- **Every issue you create gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`, and place it in **Todo**. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue you filed unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. (An **externally-filed** issue is the deliberate exception: it stays unmilestoned in Incoming until a maintainer approves it — there, the *absence* of a milestone is the signal that nobody has scheduled it yet.) Get the current milestone with: ```sh # Open milestones, soonest due date first — the first row is the current one. @@ -179,6 +182,32 @@ All work should be driven by items on the project board. - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `259d6aab`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. +### Triaging externally-filed issues + +An outside reporter has no board access, so their issue lands with **no label, no milestone, and no card**. That is the expected arrival state, not a backlog of defects — the issue enters the system through triage, in two distinct passes. + +**Pass 1 — sweep them onto the board (no approval implied).** Find the open issues with no card and bring each one in: + +1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)). +2. Add it to the board for that version. +3. Set Status to **`Incoming`**. +4. Set Priority with the [rubric](#setting-issue-priority) (v2 only). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. +5. **Leave the milestone unset.** Nobody has committed to shipping it yet, and an empty milestone is precisely what marks it as awaiting review. + +Find the unboarded ones by diffing the open issues against the board: + +```sh +gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 --json number > /tmp/open.json +gh project item-list 28 --owner modelcontextprotocol --format json --limit 700 \ + | jq -r --slurpfile o /tmp/open.json \ + '[.items[]|select(.content.type=="Issue")|.content.number] as $B + | [$o[0][].number] - $B | "unboarded: \(.)"' +``` + +**Pass 2 — approve what should ship.** A maintainer reads the Incoming column and, for each issue worth doing, **assigns a milestone** and moves the card to **Todo** (or **In Progress** if picking it up now). That is the whole approval gesture; see the [Status recipe](#v2-board-28-gh-recipes). + +Issues that shouldn't ship stay in Incoming (or get closed). **Incoming is the review queue, and "milestoned" is the line between reviewed and not** — which is why the milestone stays off in pass 1 and why the rubric can treat a milestone as a real signal rather than a formality every issue carries. + ## Setting issue priority Every issue gets a **Priority on its board card**, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later. @@ -219,7 +248,7 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: - Carries a `bug` or security-related label -- Linked to a milestone +- Linked to a milestone — i.e. **already approved** by a maintainer. Note this is the *re-scoring* case: an externally-filed issue being scored in triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing. - High engagement (many comments or reactions) - Assigned to someone - A sub-issue of a larger epic @@ -270,7 +299,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) - **Both boards start new items in `Incoming`.** A card only leaves Incoming when a maintainer has looked at it and approved the work — that is what Todo means on either board. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). + **On both boards, `Incoming` is the review queue for externally-filed issues** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging externally-filed issues](#triaging-externally-filed-issues)). A card only leaves Incoming when a maintainer has looked at it and approved the work by assigning a milestone, at which time it should move to **Todo** — unless the maintainer has chosen to work on it, in which case it should move to **In Progress**. Assigning the milestone *is* the approval act, so the two always go together: a milestoned card sitting in Incoming is a card whose approval was never recorded, and a Todo card with no milestone claims an approval nobody made. An issue **you** file skips Incoming entirely — filing it is approving it, so it starts milestoned in Todo. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). #### V2 board (#28) `gh` recipes @@ -298,7 +327,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | In Review | `159c8a02` | | Done | `259d6aab` | -Use **Incoming** for newly filed, untriaged work, **Todo** once a maintainer has approved it and it's ready to pick up, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. +Use **Incoming** for an externally-filed issue awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you filed yourself, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. @@ -364,16 +393,26 @@ gh project item-edit \ --single-select-option-id 195df262 ``` -The full one-liner for a **new** issue — add it, then set Status and Priority (both are required; here Incoming + Medium): +The full one-liner for an issue **you just created** — add it, then set Status and Priority (both required). It goes to **Todo**, because filing it was the approval, and it already carries the milestone you passed to `gh issue create`: ```sh ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') -# Status → Incoming -gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c +# Status → Todo (an issue you filed is approved by definition) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id fbdaf21e # Priority → Medium gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c ``` +For an **externally-filed** issue being swept in at triage, the only difference is the Status option — **Incoming** (`721a3d4c`) instead of Todo — and that you do **not** set a milestone: + +```sh +ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') +# Status → Incoming (awaiting maintainer review; no milestone yet) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iI8c --single-select-option-id 721a3d4c +# Priority → Medium (an assessment for queue ordering, not an approval) +gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c +``` + Each `item-edit` sets **one** field, so setting both takes two calls — there is no combined form. For an issue **already on the board** (moving an existing card, e.g. to **In Review** when its PR opens, or re-scoring its Priority), look its item id up by issue number instead of re-adding it. Keep `--limit` above the board's item count (~265 as of 2026-08-01) — past it `item-list` truncates silently, `select` matches nothing, and `item-edit --id ""` fails with an opaque node-resolution error rather than saying the limit was too low: @@ -388,7 +427,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i #### V1 board (#11) `gh` recipes -The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and it starts in **Incoming** like a v2 one. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. +The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies: one **you** file starts in **Todo**, an **externally-filed** one starts in **Incoming** awaiting review. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. | Thing | ID | | --- | --- | @@ -408,9 +447,11 @@ Status option IDs — **last verified 2026-08-01**. There is **no Priority field on this board** — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist. ```sh -# Add a v1 issue to board #11 and put it in Incoming. +# Add a v1 issue to board #11. Swap the option id for the case you're in: +# 831820cf = Incoming — an externally-filed issue awaiting review (no milestone) +# f75ad846 = Todo — an issue you filed yourself (milestoned at create time) ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') -gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id 831820cf +gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id f75ad846 ``` The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. From 04f1a56f934ce5144612e927b7d63025a3098aaa Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 15:40:49 -0400 Subject: [PATCH 022/941] =?UTF-8?q?docs:=20make=20triage=20runnable=20?= =?UTF-8?q?=E2=80=94=20state-based=20test,=20both-board=20sweep,=20board?= =?UTF-8?q?=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the same issue: the triage rules were clear on the happy path but three gaps meant a bare "triage new issues" would diverge from what a maintainer actually does. Define triage by STATE, not authorship. An issue needs triage when it arrives with no board card and no milestone — whoever filed it. Framing it as "externally-filed" was wrong: a maintainer opening an issue by hand in the GitHub UI lands in exactly the same state, because write access makes the board reachable, not automatic. What puts an issue in Todo is somebody performing the approval, not who owns the account. Renamed the section and reworded every dependent passage accordingly. Fix the discovery query, which diffed open issues against board #28 only. Verified against the live boards: it reports #1929 as unboarded when that issue is correctly carded on #11, so following it literally double-boards the issue — recreating a defect a past sweep introduced. Now diffs against the union of both boards, filtered by .content.repository (an org project can hold other repos' issues; #11 carries one from modelcontextprotocol/ servers), and prints each hit's destination, since an unboarded issue that already has a milestone is approved and belongs in Todo, not Incoming. Add "The board audit" — the other drift classes a single-issue rule can't catch (double-boarded, non-Issue items, statusless cards, the Incoming ⇔ milestone invariant in both directions, wrong board for label, missing version label, missing Priority), as a table of invariant + fix plus one runnable jq check that should print 0 across the board. Both snippets were executed verbatim against the live boards. Add "Recording the score". The rubric claimed its reasoning "survives in a form someone can argue with later", but the board stores only the result and is private, so nothing kept that promise. Triage now posts the axes, the bonuses claimed, and the total as a comment. Also scope pass 2 explicitly to a human — deciding what ships in which release is not inferable from a rubric — and correct the stale note about three statusless cards on #11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 141 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 117 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4eb96861..29819c3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ All work should be driven by items on the project board. > **A v2 issue *you* create is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. > -> **This describes an issue a maintainer files, not one an outside reporter files.** An external reporter can't set a label, a milestone, or a board field, so their issue arrives with none of them — that is normal and not a defect to fix at the moment it lands. It is brought into the system by [triage](#triaging-externally-filed-issues) instead. The two paths differ in exactly one thing: filing an issue yourself *is* approving it, so it starts in **Todo** with a milestone; an external report has not been approved by anyone yet, so it starts in **Incoming** with no milestone. +> **This describes an issue created through the flow below — not every issue that appears in the repo.** An issue opened by hand, in the GitHub UI, arrives with no label, no milestone, and no card. That is true of an outside reporter's (they have no board access) *and* of a maintainer's (write access makes the board reachable, not automatic). Either way it is normal on arrival, not a defect to fix the moment it lands; it comes into the system through [triage](#triaging-unboarded-issues) instead. The two paths differ in exactly one thing: doing the create flow *is* the approval, so the issue starts in **Todo** with a milestone; an issue that arrives unboarded and unmilestoned has been approved by nobody, so it starts in **Incoming** with no milestone. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. @@ -158,12 +158,12 @@ All work should be driven by items on the project board. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). -- **Prioritize every board item.** Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an external report). Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. +- **Prioritize every board item.** Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an unboarded one). Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - **An issue you create → `Todo`.** You only file an issue for work you intend to happen, so filing it is approving it. It gets a milestone and lands in Todo, ready to be picked up. Work you are starting immediately goes straight to **In Progress** instead. - - **An externally-filed issue → `Incoming`, no milestone.** Nobody has evaluated it yet, so it is not approved and gets no release bucket. See [Triaging externally-filed issues](#triaging-externally-filed-issues). Never park an unreviewed report in Todo — Todo asserts a maintainer signed off, and using it as the inbox erases that distinction and quietly promotes unreviewed work into the queue. + - **An issue that arrives unboarded and unmilestoned → `Incoming`.** Nobody has evaluated it yet, so it is not approved and gets no release bucket — whoever filed it. See [Triaging unboarded issues](#triaging-unboarded-issues). Never park an unreviewed issue in Todo — Todo asserts a maintainer signed off, and using it as the inbox erases that distinction and quietly promotes unreviewed work into the queue. - **Priority is v2-only.** Board #28 has a Priority field; board #11 does not. A v1 issue gets a Status and nothing else. -- **Every issue you create gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`, and place it in **Todo**. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue you filed unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. (An **externally-filed** issue is the deliberate exception: it stays unmilestoned in Incoming until a maintainer approves it — there, the *absence* of a milestone is the signal that nobody has scheduled it yet.) Get the current milestone with: +- **Every issue you create gets a milestone — no exceptions.** Set it at create time with `gh issue create --milestone <title> ...`, and place it in **Todo**. **If the user didn't specify one, default to the current milestone**: the open milestone with the nearest due date. Never leave an issue you filed unmilestoned pending a decision — an unmilestoned issue drops out of release planning silently, the same way an unlabeled one drops out of version filtering. Moving it later is one command; noticing it was never set is the hard part. (An issue that arrives **unboarded** is the deliberate exception: it stays unmilestoned in Incoming until a maintainer approves it — there, the *absence* of a milestone is the signal that nobody has scheduled it yet.) Get the current milestone with: ```sh # Open milestones, soonest due date first — the first row is the current one. @@ -182,32 +182,106 @@ All work should be driven by items on the project board. - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `259d6aab`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. - If new tasks are discovered or requested during development, create issues and add them to the board. -### Triaging externally-filed issues +### Triaging unboarded issues -An outside reporter has no board access, so their issue lands with **no label, no milestone, and no card**. That is the expected arrival state, not a backlog of defects — the issue enters the system through triage, in two distinct passes. +**An issue needs triage when it arrives with no board card and no milestone — regardless of who filed it.** That is the whole test, and it is deliberately about *state*, not authorship. -**Pass 1 — sweep them onto the board (no approval implied).** Find the open issues with no card and bring each one in: +It's tempting to frame this as "issues from outside reporters," but that's the wrong line. An outside reporter has no board access, so their issue necessarily lands this way — but so does a **maintainer's**, whenever they open one by hand instead of through the [documented create flow](#issue-driven-work-style). Write access makes the board *reachable*, not automatic. What puts an issue in Todo is somebody performing the approval — labeling it, milestoning it, and carding it — and an issue nobody did that for has not been approved, whoever's name is on it. + +So don't check the author's permissions; check whether the work was done. Arriving unboarded and unmilestoned is a normal state, not a defect to fix at the moment it lands — the issue enters the system through triage, in two distinct passes. + +**"Triage new issues" means running pass 1 over every unboarded open issue, then the [board audit](#the-board-audit) below.** Pass 2 is a human judgment call and is never done unprompted. + +#### Pass 1 — sweep them onto the board (no approval implied) + +For each open issue with no card: 1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)). -2. Add it to the board for that version. +2. Add it to the board for that version — **`v2` → #28, `v1` → #11**. 3. Set Status to **`Incoming`**. -4. Set Priority with the [rubric](#setting-issue-priority) (v2 only). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. +4. Set Priority with the [rubric](#setting-issue-priority) (v2 only), and **record the score in a comment** (see [Recording the score](#recording-the-score)). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. 5. **Leave the milestone unset.** Nobody has committed to shipping it yet, and an empty milestone is precisely what marks it as awaiting review. -Find the unboarded ones by diffing the open issues against the board: +**The one exception is an unboarded issue that already carries a milestone.** Someone recorded the approval and only the card is missing, so don't demote it to Incoming — board it straight into **Todo**, keeping the milestone. Otherwise triage would silently un-approve work a maintainer had already scheduled. + +Find the unboarded ones by diffing the open issues against **both boards**. Diffing against #28 alone is wrong: a `v1` issue correctly carded on #11 is reported as unboarded and gets double-boarded (a real defect a past sweep introduced — #1929 reproduced it). ```sh -gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 --json number > /tmp/open.json -gh project item-list 28 --owner modelcontextprotocol --format json --limit 700 \ - | jq -r --slurpfile o /tmp/open.json \ - '[.items[]|select(.content.type=="Issue")|.content.number] as $B - | [$o[0][].number] - $B | "unboarded: \(.)"' +D=$(mktemp -d) +gh issue list --repo modelcontextprotocol/inspector --state open --limit 1000 \ + --json number,milestone > "$D/open.json" +# Union of BOTH boards, filtered to this repo — org boards can hold other repos' issues. +for P in 28 11; do + gh project item-list $P --owner modelcontextprotocol --format json --limit 700 \ + | jq '[.items[] | select(.content.type=="Issue" + and .content.repository=="modelcontextprotocol/inspector") + | .content.number]' +done | jq -s 'add' > "$D/boarded.json" +# Prints the destination too: milestoned already → Todo, otherwise → Incoming. +jq -r --slurpfile b "$D/boarded.json" \ + '.[] | select(.number as $n | ($b[0]|index($n))|not) + | "#\(.number)\t→ \(if .milestone then "Todo (has milestone \(.milestone.title))" else "Incoming" end)"' \ + "$D/open.json" ``` -**Pass 2 — approve what should ship.** A maintainer reads the Incoming column and, for each issue worth doing, **assigns a milestone** and moves the card to **Todo** (or **In Progress** if picking it up now). That is the whole approval gesture; see the [Status recipe](#v2-board-28-gh-recipes). +#### Pass 2 — approve what should ship + +A maintainer reads the Incoming column and, for each issue worth doing, **assigns a milestone** and moves the card to **Todo** (or **In Progress** if picking it up now). That is the whole approval gesture; see the [Status recipe](#v2-board-28-gh-recipes). + +This pass is **a judgment call reserved for a human** — deciding what ships in which release is not something to infer from a rubric. Never promote a card out of Incoming as part of a routine sweep; "triage new issues" stops at the end of pass 1. Issues that shouldn't ship stay in Incoming (or get closed). **Incoming is the review queue, and "milestoned" is the line between reviewed and not** — which is why the milestone stays off in pass 1 and why the rubric can treat a milestone as a real signal rather than a formality every issue carries. +#### The board audit + +Sweeping in the unboarded issues is only the most visible defect class. A board drifts in several other ways that no single-issue rule catches, so **finish a triage run with the audit below** — every check should print `0`. Each maps to a rule stated elsewhere in this document; a non-zero count means the board contradicts the rule, not that the rule needs revisiting. + +| Check | Invariant | Fix | +| --- | --- | --- | +| Double-boarded | An issue has a card on **one** board, the one matching its version label | Delete the wrong-board card (`gh project item-delete`) | +| Non-Issue items | **Only issues go on a board** — never PRs, never drafts | Delete the item; the PR is tracked via its issue's `Closes #N` | +| No Status | Every card carries a Status | Set one — `Incoming` if unmilestoned, else by where it actually is | +| `Incoming` **with** a milestone | Incoming ⇔ no milestone | Approval was never recorded: move to **Todo**, or clear the milestone | +| Past Incoming **without** a milestone | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming | +| Wrong board for label | `v1` → #11, `v2` → #28 | Move the card to the right board | +| No version label | Every issue carries exactly one of `v1`/`v2` | Apply it (`v2` unless it's a fix for released v1 behavior) | +| No Priority (#28) | Every board item is prioritized | Score it with the [rubric](#setting-issue-priority) | + +```sh +D=$(mktemp -d); R=modelcontextprotocol/inspector +gh issue list --repo $R --state open --limit 1000 --json number,labels,milestone > "$D/i.json" +for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ + --format json --limit 700 > "$D/b$P.json"; done +jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' + ($o[0] | map({key:(.number|tostring), + value:{lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $I + | def own($s): [$s[].items[] | select(.content.repository==$R)]; + [own($a)[] | select(.content.type=="Issue") | {n:.content.number, s:.status, p:.priority}] as $B28 + | [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11 + | def ms($n): ($I[($n|tostring)].ms // null); + def lab($n): ($I[($n|tostring)].lab // []); + def open($n): ($I[($n|tostring)] != null); + { + "double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))], + "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | .content.number], + "no Status": [($B28[], $B11[]) | select(.s==null) | .n], + "Incoming w/ milestone": [($B28[], $B11[]) | select(.s=="Incoming" and ms(.n)!=null) | .n], + "past Incoming, no ms": [($B28[], $B11[]) | select(.s!=null and .s!="Incoming" and .s!="Done" + and open(.n) and ms(.n)==null) | .n], + "v1 label on #28": [$B28[] | select(lab(.n)|index("v1")) | .n], + "v2 label on #11": [$B11[] | select(lab(.n)|index("v2")) | .n], + "open, no version label":[$o[0][] | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], + "#28 open, no Priority": [$B28[] | select(.p==null and open(.n)) | .n] + } | to_entries[] | "\(.value|length)\t\(.key)\t\(.value[0:10])"' +``` + +Two things the queries must account for, both learned the hard way: + +- **Filter by repository.** These are **org** projects and can hold cards from any repo in the org — board #11 currently carries one `modelcontextprotocol/servers` issue. Without the `.content.repository` filter it reads as a statusless-card defect and "fixing" it would mean editing another repo's tracking. +- **Only open issues have a `$I` entry.** A closed issue still has a card (correctly, in `Done`), so a check that treats "no milestone found" as a violation must gate on `open(.n)` or it flags every closed card. + +**Do not "fix" a `Done` card that is missing a milestone.** Cards predating a rule are not defects to backfill in bulk — the audit exists to stop *new* drift, and rewriting settled history destroys the record of when a rule started being enforced. + ## Setting issue priority Every issue gets a **Priority on its board card**, set when you add the issue to the board. Score it rather than assert it: rate two axes 1–5, add the signal bonuses, and read the total off the band table. The point is that two people triaging the same issue land in the same place, and that the reasoning survives in a form someone can argue with later. @@ -248,7 +322,7 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: - Carries a `bug` or security-related label -- Linked to a milestone — i.e. **already approved** by a maintainer. Note this is the *re-scoring* case: an externally-filed issue being scored in triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing. +- Linked to a milestone — i.e. **already approved** by a maintainer. Note this is the *re-scoring* case: an issue being scored in a pass-1 triage has no milestone yet by rule, so it never earns this one. If you find yourself applying it to every issue in a batch, the milestone is being used as a formality rather than as approval, and the bonus has become a constant that discriminates nothing. - High engagement (many comments or reactions) - Assigned to someone - A sub-issue of a larger epic @@ -267,6 +341,25 @@ Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signa Set the resulting level on the board card with the Priority recipe in the [V2 board (#28) `gh` recipes](#v2-board-28-gh-recipes) below. +### Recording the score + +The board stores only the *result* — a card reading `High` with no trace of how it got there. Since [the boards are private](#trust-boundary-who-can-set-what), that result is also invisible to the reporter. So **when you score an issue during triage, post the arithmetic as a comment on the issue.** Without it the rubric's promise — that the reasoning "survives in a form someone can argue with later" — is not kept by anything, and a later re-scoring has no way to tell a considered judgment from a guess. + +Keep it to the two axes, the bonuses claimed, and the total: + +```sh +gh issue comment <N> --repo modelcontextprotocol/inspector --body \ +'**Triage:** Priority **Medium** (total 7) + +- Severity 3 — a real feature is broken; workaround is partial +- Urgency 2 — wanted eventually, nothing blocked on it +- Bonuses: +1 `bug` label, +1 reporter set Fields → Priority to High + +Board: #28, Status `Incoming` (awaiting maintainer review — no milestone yet).' +``` + +Name the bonuses you claimed rather than just summing them — the milestone bonus in particular should be conspicuously absent on a pass-1 triage, and a comment that lists it is a visible sign the [approval semantics](#triaging-unboarded-issues) were misapplied. + ### Trust boundary: who can set what **The boards are private** (`public: false`, both #28 and #11 — verified 2026-08-01). The Status and Priority a maintainer assigns are visible only to people with project access: a reporter cannot see them, cannot set them, and will never learn how their issue was scored. Board priority is a maintainers' working queue, not a published commitment. @@ -299,7 +392,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) - **On both boards, `Incoming` is the review queue for externally-filed issues** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging externally-filed issues](#triaging-externally-filed-issues)). A card only leaves Incoming when a maintainer has looked at it and approved the work by assigning a milestone, at which time it should move to **Todo** — unless the maintainer has chosen to work on it, in which case it should move to **In Progress**. Assigning the milestone *is* the approval act, so the two always go together: a milestoned card sitting in Incoming is a card whose approval was never recorded, and a Todo card with no milestone claims an approval nobody made. An issue **you** file skips Incoming entirely — filing it is approving it, so it starts milestoned in Todo. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). + **On both boards, `Incoming` is the review queue for issues that arrived unboarded** — swept in at triage with a Priority but deliberately **no milestone** (see [Triaging unboarded issues](#triaging-unboarded-issues)). A card only leaves Incoming when a maintainer has looked at it and approved the work by assigning a milestone, at which time it should move to **Todo** — unless the maintainer has chosen to work on it, in which case it should move to **In Progress**. Assigning the milestone *is* the approval act, so the two always go together: a milestoned card sitting in Incoming is a card whose approval was never recorded, and a Todo card with no milestone claims an approval nobody made. An issue created through the documented flow skips Incoming entirely — doing that flow is approving it, so it starts milestoned in Todo. The two boards are otherwise separate projects with their own field and option ids; never reuse one board's ids against the other (they are rejected with "option Id does not belong to the field", so the mistake is at least loud). #### V2 board (#28) `gh` recipes @@ -327,7 +420,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | In Review | `159c8a02` | | Done | `259d6aab` | -Use **Incoming** for an externally-filed issue awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you filed yourself, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. +Use **Incoming** for an issue that arrived unboarded and is awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you created through the documented flow, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. @@ -403,7 +496,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BJVxtzg5iJE4 --single-select-option-id da944a9c ``` -For an **externally-filed** issue being swept in at triage, the only difference is the Status option — **Incoming** (`721a3d4c`) instead of Todo — and that you do **not** set a milestone: +For an issue **swept in at triage** (arrived unboarded, no milestone), the only difference is the Status option — **Incoming** (`721a3d4c`) instead of Todo — and that you do **not** set a milestone: ```sh ITEM_ID=$(gh project item-add 28 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') @@ -427,7 +520,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i #### V1 board (#11) `gh` recipes -The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies: one **you** file starts in **Todo**, an **externally-filed** one starts in **Incoming** awaiting review. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. +The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and the same Incoming/Todo split applies: one created through the documented flow starts in **Todo**, one that arrived unboarded starts in **Incoming** awaiting review. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. | Thing | ID | | --- | --- | @@ -448,13 +541,13 @@ There is **no Priority field on this board** — the priority rubric applies to ```sh # Add a v1 issue to board #11. Swap the option id for the case you're in: -# 831820cf = Incoming — an externally-filed issue awaiting review (no milestone) -# f75ad846 = Todo — an issue you filed yourself (milestoned at create time) +# 831820cf = Incoming — arrived unboarded, awaiting review (no milestone) +# f75ad846 = Todo — created through the documented flow (milestoned at create time) ITEM_ID=$(gh project item-add 11 --owner modelcontextprotocol --url <issue-url> --format json --jq '.id') gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-id PVTSSF_lADOCt2Azc4BA5szzgzkS-g --single-select-option-id f75ad846 ``` -The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. +The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that board #11 also carries **one card from another repo** (`modelcontextprotocol/servers`), which has no Status. That is why every audit query filters on `.content.repository` — an org project can hold items from any repo in the org, and an unfiltered check reports that card as a statusless-card defect. It is not ours to fix. (The three *inspector* cards that carried no Status on 2026-08-01 predated the `Incoming` addition and have since been set; neither case was evidence of an orphaning event.) ### Always test new or modified code - Ensure all code has corresponding tests From a9238426a6fdb3561163b1686dc5b38f47f7a582 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 16:25:45 -0400 Subject: [PATCH 023/941] =?UTF-8?q?docs:=20Done=20means=20shipped=20?= =?UTF-8?q?=E2=80=94=20remove,=20don't=20park,=20issues=20closed=20any=20o?= =?UTF-8?q?ther=20way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board card in Done asserts that the work shipped, and Done is read as the record of what a milestone actually delivered. Nothing said so, so an issue closed as a duplicate or not-planned was being moved to Done like any other close — which makes that record wrong in a way no later reader can detect: counting Done cards can no longer tell a shipped fix from a report closed as a duplicate of one. New rule: exactly two things earn a card a place in Done — its PR merged, or it is a parent whose last sub-issue closed. Every other close (duplicate, won't fix, not planned, obsolete, superseded) means nothing shipped, so the card is deleted instead. Deleting a card touches only the board; the issue keeps its labels and comments and stays searchable, so nothing is lost. Also notes that the close reason is the machine-readable form of the same distinction, and that `gh issue close --reason` cannot express `duplicate` — it accepts only completed/not planned, so duplicate must be set through the API or the web UI's "Mark as duplicate" (which also records a duplicate-of link). Adds the invariant to the board audit as a tenth check. That required the audit to read closed issues too, so its fetch moves to --state all with stateReason, `open(n)` becomes a state test rather than "present in the map", and --limit must now clear the repo's total issue count, not just the open ones. The two wrong-board label checks are scoped to open issues in the same pass — over all history they flag four long-closed cards whose labels nobody intends to rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 51 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 29819c3f..6a04eb37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,6 +180,21 @@ All work should be driven by items on the project board. - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) - **On merge of a v2 PR, manually close its issue and move the board item to Done** (option id `259d6aab`), since auto-close won't fire on `v2/main`. Keep the `Closes #N` line anyway so the issues close automatically if/when `v2/main` is eventually merged to `main`. +- **`Done` means the work shipped. An issue closed for any other reason is REMOVED from the board, not moved to Done.** Exactly two things earn a card a place in Done: + 1. Its **PR merged**, or + 2. it is a **parent whose last sub-issue closed** — the work shipped across its children rather than through one PR of its own. + + Anything else — duplicate, won't fix, not planned, obsolete, superseded — means nothing shipped, so **delete the card**: + + ```sh + ITEM_ID=$(gh project item-list 28 --owner modelcontextprotocol --format json --limit 500 \ + --jq '.items[] | select(.content.number==<ISSUE_NUMBER>) | .id') + gh project item-delete 28 --owner modelcontextprotocol --id "$ITEM_ID" + ``` + + Deleting the card removes it from the board only — **the issue itself is untouched**, keeps its labels and comments, and stays searchable and linkable forever. Nothing is lost; the board simply stops claiming the work was delivered. + + Done is read as the record of what a milestone actually delivered, so a card parked there asserts something shipped. A duplicate sitting in Done makes that record wrong in a way nobody can detect later — counting Done cards can no longer distinguish a shipped fix from a report closed as a duplicate of one. Board columns are a workflow signal; GitHub's issue list is the archive. The close **reason** is the machine-readable form of the same distinction: `gh issue close --reason` accepts only `completed` and `not planned`, so **`duplicate` must be set through the API** — `gh api repos/OWNER/REPO/issues/N -X PATCH -f state=closed -f state_reason=duplicate` — or via "Mark as duplicate" in the web UI, which additionally records a duplicate-of link. - If new tasks are discovered or requested during development, create issues and add them to the board. ### Triaging unboarded issues @@ -246,32 +261,42 @@ Sweeping in the unboarded issues is only the most visible defect class. A board | Wrong board for label | `v1` → #11, `v2` → #28 | Move the card to the right board | | No version label | Every issue carries exactly one of `v1`/`v2` | Apply it (`v2` unless it's a fix for released v1 behavior) | | No Priority (#28) | Every board item is prioritized | Score it with the [rubric](#setting-issue-priority) | +| Closed, not shipped, still carded | **Done means the work shipped** — a card closed as duplicate/not-planned is deleted, not parked | Delete the card (`gh project item-delete`) | ```sh D=$(mktemp -d); R=modelcontextprotocol/inspector -gh issue list --repo $R --state open --limit 1000 --json number,labels,milestone > "$D/i.json" +# --limit must exceed the repo's TOTAL issue count (884 as of 2026-08-05), not just the open ones — +# the last check below reads closed issues' state reasons. +gh issue list --repo $R --state all --limit 2000 \ + --json number,state,stateReason,labels,milestone > "$D/i.json" for P in 28 11; do gh project item-list $P --owner modelcontextprotocol \ --format json --limit 700 > "$D/b$P.json"; done jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b11.json" --arg R "$R" ' - ($o[0] | map({key:(.number|tostring), - value:{lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $I + ($o[0] | map({key:(.number|tostring), value:{st:.state, sr:(.stateReason // ""), + lab:[.labels[].name], ms:(.milestone.title // null)}}) | from_entries) as $M | def own($s): [$s[].items[] | select(.content.repository==$R)]; + def I($n): ($M[($n|tostring)] // null); + def ms($n): (I($n).ms // null); + def lab($n): (I($n).lab // []); + def isopen($n): (I($n).st == "OPEN"); + def shipped($n): (I($n).sr == "COMPLETED"); [own($a)[] | select(.content.type=="Issue") | {n:.content.number, s:.status, p:.priority}] as $B28 | [own($b)[] | select(.content.type=="Issue") | {n:.content.number, s:.status}] as $B11 - | def ms($n): ($I[($n|tostring)].ms // null); - def lab($n): ($I[($n|tostring)].lab // []); - def open($n): ($I[($n|tostring)] != null); - { + | { "double-boarded": [$B28[].n | select(. as $n | [$B11[].n]|index($n))], "non-Issue on a board": [(own($a)[], own($b)[]) | select(.content.type!="Issue") | .content.number], "no Status": [($B28[], $B11[]) | select(.s==null) | .n], "Incoming w/ milestone": [($B28[], $B11[]) | select(.s=="Incoming" and ms(.n)!=null) | .n], "past Incoming, no ms": [($B28[], $B11[]) | select(.s!=null and .s!="Incoming" and .s!="Done" - and open(.n) and ms(.n)==null) | .n], - "v1 label on #28": [$B28[] | select(lab(.n)|index("v1")) | .n], - "v2 label on #11": [$B11[] | select(lab(.n)|index("v2")) | .n], - "open, no version label":[$o[0][] | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], - "#28 open, no Priority": [$B28[] | select(.p==null and open(.n)) | .n] + and isopen(.n) and ms(.n)==null) | .n], + "v1 label on #28": [$B28[] | select(isopen(.n) and (lab(.n)|index("v1"))) | .n], + "v2 label on #11": [$B11[] | select(isopen(.n) and (lab(.n)|index("v2"))) | .n], + "open, no version label":[$o[0][] | select(.state=="OPEN") + | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], + "#28 open, no Priority": [$B28[] | select(.p==null and isopen(.n)) | .n], + "closed unshipped, still carded": + [($B28[], $B11[]) | select(I(.n)!=null and (isopen(.n)|not) + and (shipped(.n)|not)) | .n] } | to_entries[] | "\(.value|length)\t\(.key)\t\(.value[0:10])"' ``` @@ -420,7 +445,7 @@ Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01** | In Review | `159c8a02` | | Done | `259d6aab` | -Use **Incoming** for an issue that arrived unboarded and is awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you created through the documented flow, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. +Use **Incoming** for an issue that arrived unboarded and is awaiting review (no milestone yet), **Todo** once a maintainer has approved it by assigning a milestone — including an issue you created through the documented flow, which starts here — **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge — and *only* on merge (or a parent's last sub-issue closing). An issue closed for any other reason has its **card deleted**, because [Done asserts the work shipped](#issue-driven-work-style). The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. The milestone is the machine-checkable form of that claim — Incoming ⇔ no milestone, everything past it ⇔ milestoned. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. From d0b683d4bfa9e313cbe11877bcb59eab1fd8a7b6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:04:02 -0400 Subject: [PATCH 024/941] docs: require a type label (bug/enhancement/documentation/chore/question) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The version label says which line work belongs to; nothing said what KIND of work it is. In practice that meant reaching for bug or enhancement and pressing everything else into one of them — a migration guide and a tsup->tsdown swap both landing as "enhancement", which degrades the label to "not a bug" and makes filtering by it meaningless. Every issue created or triaged now carries exactly one of bug, enhancement, documentation, chore, or question, independent of its v1/v2 label. Adds a table of what each is and is not for, and states the anti-pattern explicitly so the two familiar labels don't absorb the other three. A PR needs no type label — it is classified through the issue it closes, the same way it is tracked through that issue's board card. Wires the requirement into the three places that would otherwise drift: the "not created until" callout, triage pass 1 (an outside reporter can set neither label, so both are applied in the same step), and the board audit, which gains an "open, no type label" check and a matching row in the invariant table. Note `chore` did not exist as a repo label and was created for this; the existing `dependencies` label is Dependabot's and scoped to PRs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- AGENTS.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a04eb37..3a482145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,7 +148,7 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue *you* create is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue *you* create is not "created" until it is labeled `v2` **and** typed (`bug`/`enhancement`/`documentation`/`chore`/`question`), given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. > > **This describes an issue created through the flow below — not every issue that appears in the repo.** An issue opened by hand, in the GitHub UI, arrives with no label, no milestone, and no card. That is true of an outside reporter's (they have no board access) *and* of a maintainer's (write access makes the board reachable, not automatic). Either way it is normal on arrival, not a defect to fix the moment it lands; it comes into the system through [triage](#triaging-unboarded-issues) instead. The two paths differ in exactly one thing: doing the create flow *is* the approval, so the issue starts in **Todo** with a milestone; an issue that arrives unboarded and unmilestoned has been approved by nobody, so it starts in **Incoming** with no milestone. @@ -158,6 +158,19 @@ All work should be driven by items on the project board. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). +- **Label by type — every issue you create or triage carries exactly one of `bug` / `enhancement` / `documentation` / `chore` / `question`.** The version label says *which line* the work belongs to; the type label says *what kind of work it is*, and the two are independent — every issue needs both. Set it at create time (`gh issue create --label v2 --label bug ...`) or, for one arriving through [triage](#triaging-unboarded-issues), in the same pass that applies the version label. + + | Type | Use for | Not for | + | --- | --- | --- | + | `bug` | Something is broken, wrong, or regressed against its intended behavior | A missing capability that was never built | + | `enhancement` | A new capability, or extending an existing one — features, spec support, tracking issues for either | A cleanup with no behavior change | + | `documentation` | Prose deliverables — READMEs, guides, `specification/` docs, `AGENTS.md` rules | Code that happens to need a doc update | + | `chore` | Maintenance with no user-facing behavior change — dependency work, build/CI tooling, refactors, cleanup | Anything a user would notice | + | `question` | An open question or discussion with no agreed deliverable yet | Work someone has already decided to do | + + **Don't force the binary.** `bug` and `enhancement` are the two most reached for, and pressing a docs task or a dependency pin into `enhancement` degrades it to "not a bug" — at which point filtering by it stops telling you anything. If an issue is really a migration guide, say `documentation`; if it is a `tsup` → `tsdown` migration, say `chore`. + + A **PR** does not need a type label — it is classified through the issue it closes, the same way it is tracked through that issue's board card. - **Prioritize every board item.** Every issue must have a Priority (Urgent, High, Medium, or Low) on its card — set when the card is created, whether that's at issue-creation time (yours) or at triage (an unboarded one). Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - **An issue you create → `Todo`.** You only file an issue for work you intend to happen, so filing it is approving it. It gets a milestone and lands in Todo, ready to be picked up. Work you are starting immediately goes straight to **In Progress** instead. @@ -211,7 +224,7 @@ So don't check the author's permissions; check whether the work was done. Arrivi For each open issue with no card: -1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)). +1. Apply the version label (`v2` unless it's a fix for released v1 behavior — see [Label by version](#issue-driven-work-style)) **and the type label** (`bug` / `enhancement` / `documentation` / `chore` / `question` — see [Label by type](#issue-driven-work-style)). An outside reporter cannot set either, so both are applied here. 2. Add it to the board for that version — **`v2` → #28, `v1` → #11**. 3. Set Status to **`Incoming`**. 4. Set Priority with the [rubric](#setting-issue-priority) (v2 only), and **record the score in a comment** (see [Recording the score](#recording-the-score)). This is an *assessment*, not an approval — it's how the queue gets ordered for the maintainer who reviews it next. @@ -260,6 +273,7 @@ Sweeping in the unboarded issues is only the most visible defect class. A board | Past Incoming **without** a milestone | Everything past Incoming ⇔ milestoned | Claims an approval nobody made: milestone it, or move back to Incoming | | Wrong board for label | `v1` → #11, `v2` → #28 | Move the card to the right board | | No version label | Every issue carries exactly one of `v1`/`v2` | Apply it (`v2` unless it's a fix for released v1 behavior) | +| No type label | Every issue carries one of `bug`/`enhancement`/`documentation`/`chore`/`question` | Classify it per [Label by type](#issue-driven-work-style) | | No Priority (#28) | Every board item is prioritized | Score it with the [rubric](#setting-issue-priority) | | Closed, not shipped, still carded | **Done means the work shipped** — a card closed as duplicate/not-planned is deleted, not parked | Delete the card (`gh project item-delete`) | @@ -293,6 +307,10 @@ jq -nr --slurpfile o "$D/i.json" --slurpfile a "$D/b28.json" --slurpfile b "$D/b "v2 label on #11": [$B11[] | select(isopen(.n) and (lab(.n)|index("v2"))) | .n], "open, no version label":[$o[0][] | select(.state=="OPEN") | select(([.labels[].name]|index("v1") or index("v2"))|not) | .number], + "open, no type label": [$o[0][] | select(.state=="OPEN") + | select(([.labels[].name] | index("bug") or index("enhancement") + or index("documentation") or index("chore") + or index("question"))|not) | .number], "#28 open, no Priority": [$B28[] | select(.p==null and isopen(.n)) | .n], "closed unshipped, still carded": [($B28[], $B11[]) | select(I(.n)!=null and (isopen(.n)|not) From 22da48c652d035c7071f4cfef72f5e44319c95df Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:46:10 -0400 Subject: [PATCH 025/941] fix: ship clients/web/static so the MCP Apps sandbox proxy loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clients/web/static/sandbox_proxy.html` is a committed source file, not a build artifact, and it was never listed in the root package.json "files" allowlist. `sandbox-controller.ts` reads it at runtime as `<runner dir>/../static/sandbox_proxy.html`, so it resolved fine in-repo but was absent from every published tarball — the read threw and the controller served its "Sandbox not loaded" fallback page, breaking the Apps tab for anyone running `npx @modelcontextprotocol/inspector`. v1 shipped the equivalent as `server/static` in both the root and the server workspace "files" lists; that entry has no counterpart in v2. Add `clients/web/static` to the allowlist, and assert it in pack:verify twice over — once in the tarball packlist and once on disk after install, since what the runtime needs is the path *relative to* clients/web/build rather than mere presence in the tarball. No .npmignore change is needed: clients/web/.gitignore does not list `static`, so the nested-gitignore packlist hazard that hid `build/` does not apply here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 1 + package.json | 1 + scripts/pack-and-verify.mjs | 31 ++++++++++++++++++++----------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a597b665..5a5d7a2f 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. +- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved *relative to* `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball diff --git a/package.json b/package.json index d9472940..fe55ae96 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "clients/launcher/build", "clients/web/build", "clients/web/dist", + "clients/web/static", "clients/cli/build", "clients/tui/build", "scripts/install-clients.mjs" diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index c19bbfe4..ff1dd310 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -22,8 +22,8 @@ * * 1. builds every client (`npm run build`); * 2. packs the publishable tarball (`npm pack`) and inspects its file list — - * asserting NO source maps ship and that `clients/web/{build,dist}` are - * both present (the two packaging fixes this work landed); + * asserting NO source maps ship and that `clients/web/{build,dist,static}` + * are all present (the packaging fixes this work landed); * 3. installs that tarball into a fresh temp dir (real `npm install <tgz>`, * which runs the package's `postinstall`); * 4. runs the installed `mcp-inspector` bin: `--help`, `--cli`/`--tui` help @@ -164,26 +164,31 @@ if (maps.length > 0) { } // 2b. Runtime files that are easy to omit from the packlist and only fail once -// installed: both web artifacts — the prod server runner (build) AND the SPA -// (dist). `clients/web/build` was previously dropped by the nested -// .gitignore. (The version the CLI/TUI report is read from the root -// package.json — always shipped — via readInspectorVersion(), so no client -// package.json needs to ship; that read is exercised by driving the bin in -// step 4.) +// installed: the web artifacts — the prod server runner (build), the SPA +// (dist), and the MCP Apps sandbox proxy page (static). `clients/web/build` +// was previously dropped by the nested .gitignore; `clients/web/static` was +// never listed in the root "files" allowlist at all, so the Apps tab failed +// with "Sandbox not loaded" on every published build (#1859). None of these +// are checked-in-tree failures — only an installed tarball reveals them. +// (The version the CLI/TUI report is read from the root package.json — +// always shipped — via readInspectorVersion(), so no client package.json +// needs to ship; that read is exercised by driving the bin in step 4.) for (const required of [ "clients/web/build/index.js", "clients/web/dist/index.html", + "clients/web/static/sandbox_proxy.html", ]) { if (!tarredPaths.includes(required)) { fail( `expected \`${required}\` in the published tarball but it is missing — ` + - `check the "files" field in clients/web/package.json`, + `check the "files" field in the root package.json (and that ` + + `clients/web/.npmignore does not exclude it)`, ); } } console.log( `pack:verify — tarball OK: ${tarredPaths.length} files, no source maps, ` + - `clients/web/{build,dist} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, + `clients/web/{build,dist,static} present (${(packInfo.unpackedSize / 1048576).toFixed(2)} MB unpacked)`, ); // --------------------------------------------------------------------------- @@ -229,10 +234,14 @@ try { if (!existsSync(bin)) { fail(`installed \`mcp-inspector\` bin not found at ${bin}`); } - // Confirm the two packaging fixes survived install onto disk. + // Confirm the packaging fixes survived install onto disk. The sandbox proxy + // is resolved at runtime as `<build>/../static/sandbox_proxy.html`, so its + // position *relative to* clients/web/build is what matters, not just presence + // in the tarball (#1859). for (const required of [ join(installedPkg, "clients", "web", "build", "index.js"), join(installedPkg, "clients", "web", "dist", "index.html"), + join(installedPkg, "clients", "web", "static", "sandbox_proxy.html"), join(installedPkg, "clients", "launcher", "build", "index.js"), ]) { if (!existsSync(required)) { From 28a21c5e08c8529416ac92ec64a069310c45ee3c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 17:59:09 -0400 Subject: [PATCH 026/941] test: add an MCP App showcase config to reproduce and verify #1859 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mcp_app_demo` tool and `mcp_app_demo_widget` UI resource presets already existed in the preset registry, but no config wired them into a server — so there was no runnable way to reach a rendered MCP App, and therefore no way to exercise the sandbox proxy path that #1859 broke. `mcp-app-http.json` composes the two over plain streamable-HTTP. With it, the Apps tab renders a real widget and the packaging bug is reproducible on demand: without clients/web/static in the tarball the widget area shows "Sandbox not loaded: ENOENT … /clients/web/static/sandbox_proxy.html" instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 9 +++++++++ test-servers/configs/mcp-app-http.json | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 test-servers/configs/mcp-app-http.json diff --git a/README.md b/README.md index 5a5d7a2f..05ffe096 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| `mcp-app-http.json` | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | | `mrtr-showcase-http.json` | Every MRTR preset in one server | — | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | @@ -142,6 +143,14 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | | `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +#### MCP Apps + +`mcp-app-http.json` serves the `mcp_app_demo` tool (`_meta.ui.resourceUri`) alongside its `mcp_app_demo_widget` UI resource, so the **Apps** tab has a real App to render. It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era, not Modern. + +Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App**: the widget renders inside the sandbox iframe and exercises the host-side UI protocol surface — host-context render, `size-changed`, `ui/message`, and a log line into the **App logs** panel. Because the widget is served through the sandbox proxy page, this config is also what reproduces [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) (a missing `clients/web/static/sandbox_proxy.html` surfaces here as a "Sandbox not loaded" message in place of the widget) — a failure that only ever appeared in an installed package, never in the repo. + +For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md). + #### MRTR `modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`. diff --git a/test-servers/configs/mcp-app-http.json b/test-servers/configs/mcp-app-http.json new file mode 100644 index 00000000..445966ac --- /dev/null +++ b/test-servers/configs/mcp-app-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "mcp-app-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "mcp_app_demo" }], + "resources": [{ "preset": "mcp_app_demo_widget" }], + "transport": { + "type": "streamable-http", + "port": 3130 + } +} From cf0ed4ac78398ef9e8a8f824f3e7e3a2f4af444c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 5 Aug 2026 19:00:29 -0400 Subject: [PATCH 027/941] =?UTF-8?q?test:=20add=20smoke:web:app=20=E2=80=94?= =?UTF-8?q?=20drive=20a=20real=20MCP=20App=20end=20to=20end=20in=20headles?= =?UTF-8?q?s=20Chromium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smoke:web:browser` stops at first paint and never connects to a server, so everything downstream of the connect — the Apps tab, the sandbox controller, the UI-protocol bridge — was unexercised by any smoke. That is the code #1859 broke, and nothing would have caught a regression in it. smoke:web:app boots the same prod --web server, spawns the mcp-app-http.json composable server, and drives connect → open app → widget ready through one deep-link navigate. The assertion is the documented data-app-status="ready" contract, which the renderer only reports once the widget has loaded inside the sandbox iframe AND completed its bridge handshake — so a single attribute covers the proxy being served, the UI resource loading, and the handshake. Two mechanics found by running it, both silent-failure shaped: - server-composable.ts announces readiness on stderr, not stdout, so watching stdout alone times out with an empty diagnostic. Both streams are scanned. - the bound port is not the configured one — createTestServerHttp resolves via findAvailablePort(), which walks upward when the port is taken. The smoke parses the announced URL instead of assuming 3130. Scope: this runs against the repo build tree like every other smoke, so it would NOT have caught #1859 itself (a packaging failure; the file is always present in-repo). pack:verify owns that dimension — the two are complements, and the header comment says so. A cheap structural pre-check does assert the proxy page sits where sandbox-controller.ts resolves it, so a move/rename fails fast. Both failure modes verified by hand: with clients/web/static removed, the pre-check fires; with the pre-check bypassed, the data-app-status assertion catches it independently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- .github/workflows/main.yml | 8 +- AGENTS.md | 3 +- README.md | 2 +- package.json | 3 +- scripts/smoke-web-app.mjs | 305 +++++++++++++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 scripts/smoke-web-app.mjs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 71e9ec4c..d1763cba 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -93,9 +93,11 @@ jobs: # CLI/web). Not part of any client's `validate`: it needs the # cli/tui/launcher bundles, which `validate` above already built # (smoke:web builds clients/web/dist on demand — #1486). smoke:web:browser - # boots the prod web bundle in headless chromium (#1615). smoke:tui - # self-skips here — the Ink TUI needs a real TTY (raw mode) that headless - # CI lacks, so its boot/render check is local-only. + # boots the prod web bundle in headless chromium (#1615); smoke:web:app + # goes further and drives connect → open app → widget ready against a + # composable MCP App server (#1859). Both reuse the chromium installed + # above. smoke:tui self-skips here — the Ink TUI needs a real TTY (raw + # mode) that headless CI lacks, so its boot/render check is local-only. run: npm run smoke - name: Run Storybook play-function tests diff --git a/AGENTS.md b/AGENTS.md index b86db380..a4185baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -454,10 +454,11 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. -- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). +- **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). +- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe *and* fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would *not* have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's *browser-externalization warning* (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the *called-at-init* case (which `smoke:web:browser` also catches, but later/at runtime) **and** the *imported-but-never-called* case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin *records* the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. diff --git a/README.md b/README.md index 05ffe096..cb3f6c67 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus a headless-Chromium boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests). | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | diff --git a/package.json b/package.json index fe55ae96..d3467cf6 100644 --- a/package.json +++ b/package.json @@ -64,11 +64,12 @@ "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", - "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:browser", + "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", "smoke:cli": "node scripts/smoke-cli.mjs", "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", "smoke:web:browser": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-browser.mjs", + "smoke:web:app": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-app.mjs", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/pack-and-verify.mjs", "prepack": "npm run build", diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs new file mode 100644 index 00000000..8387476d --- /dev/null +++ b/scripts/smoke-web-app.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node +/** + * Headless-browser MCP Apps smoke for the prod web client (#1859). + * + * `smoke:web:browser` proves the bundle boots and paints its first frame. It + * stops there — it never connects to a server, so everything downstream of the + * connect (the Apps tab, the sandbox controller, the UI-protocol bridge) is + * unexercised by any smoke. This closes that gap: it drives the full + * **connect → open app → widget ready** chain against a real MCP App server. + * + * The assertion is the `data-app-status="ready"` contract documented in + * clients/web/README.md ("MCP Apps screen automation contract"): the renderer + * only reports `ready` once the widget has loaded inside the sandbox iframe and + * fired `notifications/initialized` back through the bridge. So a single + * attribute covers the whole path — sandbox controller serving the proxy page, + * the proxy loading the UI resource, and the bridge completing its handshake. + * + * ── What this does and does NOT catch ─────────────────────────────────────── + * + * This runs against the **repo build tree**, like every other `smoke:*`. That + * matters for the bug that motivated it: #1859 was a *packaging* failure — + * `clients/web/static/sandbox_proxy.html` was missing from the published + * tarball's "files" allowlist. In the repo that file is always present, so this + * smoke would have stayed green through that entire bug. + * + * The packaging dimension is owned by `npm run pack:verify`, which asserts the + * file both in the tarball packlist and on disk after a real install. Keep both: + * pack:verify proves the file *ships*, this proves the App path *works*. Neither + * subsumes the other, and the failure this one is positioned to catch is a + * regression in the sandbox/bridge code itself — which pack:verify, driving only + * `GET /`, would not notice. + * + * As a cheap extra, this does assert the proxy page exists at the location the + * runtime resolves it from (`clients/web/build/../static/…`, see + * server/sandbox-controller.ts) — which catches the file being *moved or + * renamed* without its reader being updated, a repo-tree failure pack:verify + * would only find later. + * + * Playwright is resolved with a `createRequire` based at clients/web/package.json + * rather than a bare `import("playwright")` — a bare ESM specifier resolves + * relative to scripts/, not the cwd, so `cd clients/web` in the npm script would + * NOT make it resolvable. Same gotcha as smoke:web:browser; see its header. + * + * Expects `clients/web/dist` and `clients/launcher/build` to be built first — + * the validate / CI ordering guarantees this. `test-servers/build` is built on + * demand if missing, as in smoke:cli. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; +import { join, resolve } from "node:path"; +import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { stopChild } from "./lib/child-cleanup.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const requireFromWeb = createRequire( + resolve(repoRoot, "clients/web/package.json"), +); + +const composableServer = join( + repoRoot, + "test-servers", + "build", + "server-composable.js", +); +const appConfig = join( + repoRoot, + "test-servers", + "configs", + "mcp-app-http.json", +); +// The path clients/web/server/sandbox-controller.ts resolves at runtime, from +// the built runner at clients/web/build/. Kept in sync with the `join(__dirname, +// "../static/sandbox_proxy.html")` there. +const sandboxProxyPage = join( + repoRoot, + "clients", + "web", + "static", + "sandbox_proxy.html", +); + +const HOST = "127.0.0.1"; +// Distinct from the other web smokes' ports so back-to-back runs can't collide. +const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6299"; +const TOKEN = "smoke-web-app-token"; +const APP_TOOL = "mcp_app_demo"; +// The URL the test server announces on startup. NOT derived from the config's +// port: createTestServerHttp resolves its port with findAvailablePort(), which +// walks UPWARD from the configured value when it's taken — so the config port is +// a starting hint, not a guarantee, and assuming it makes this smoke fail +// whenever anything else holds that port. The announced line is authoritative. +let mcpUrl = null; + +let mcpServer = null; +let browser = null; +const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); + +async function shutdown() { + if (browser) { + try { + await browser.close(); + } catch { + // best-effort + } + browser = null; + } + server.stop(); + if (mcpServer) { + const child = mcpServer; + mcpServer = null; + await stopChild(child, { label: "smoke:web:app", what: "MCP test server" }); + } +} + +async function fail(message) { + console.error(`smoke:web:app FAILED — ${message}`); + await shutdown(); + process.exit(1); +} + +/** Build the composable test server bundle if it isn't present yet. */ +function ensureTestServer() { + if (existsSync(composableServer)) return; + console.log( + "smoke:web:app — building test-servers (missing build output)...", + ); + const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], { + cwd: repoRoot, + stdio: "inherit", + }); + if (r.status !== 0 || !existsSync(composableServer)) { + throw new Error( + "could not build the test servers (test-servers/build/server-composable.js). " + + "Run `npm run test-servers:build` from clients/web.", + ); + } +} + +/** + * Spawn the MCP App test server and wait for it to announce its URL. + * + * Both stdio channels are piped and scanned: server-composable.ts announces + * readiness with `console.error`, so watching stdout alone never matches and + * this times out with an empty diagnostic. Piping both also keeps the child's + * noise out of the smoke's own output while still making it available in the + * failure message. + */ +async function startMcpServer() { + const child = spawn( + process.execPath, + [composableServer, "--config", appConfig], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }, + ); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (out += d)); + let exited = false; + child.on("exit", () => (exited = true)); + + for (let attempt = 0; attempt < 120; attempt++) { + // Take the port the server actually bound, not the one we asked for. + const announced = out.match(/listening at (http:\/\/\S+)/i); + if (announced) return { child, url: announced[1] }; + if (exited) throw new Error(`MCP test server exited early:\n${out}`); + await delay(250); + } + throw new Error(`MCP test server did not start within 30s:\n${out}`); +} + +/** base64url(JSON) — the appArgs encoding the deep link expects. */ +function encodeAppArgs(args) { + return Buffer.from(JSON.stringify(args)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +async function loadChromium() { + let chromium; + try { + ({ chromium } = requireFromWeb("playwright")); + } catch (err) { + // Not resolvable means devDependencies are missing — fixed by `npm install` + // at the repo root, NOT by `playwright install` (which fetches binaries). + throw new Error( + `could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, + ); + } + try { + return await chromium.launch({ headless: true }); + } catch (err) { + throw new Error( + `chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +try { + // Cheap structural check first: the sandbox proxy page must exist where the + // runtime looks for it. Fails fast with a clear cause instead of surfacing as + // an opaque "app never reached ready" 30s timeout below. + if (!existsSync(sandboxProxyPage)) { + await fail( + `sandbox proxy page missing at ${sandboxProxyPage} — clients/web/server/sandbox-controller.ts ` + + `reads it as \`join(__dirname, "../static/sandbox_proxy.html")\`; if it moved, update both ` + + `(and the "files" allowlist in the root package.json, see #1859)`, + ); + } + + ensureTestServer(); + ({ child: mcpServer, url: mcpUrl } = await startMcpServer()); + await server.waitForReady(); + browser = await loadChromium(); + const page = await browser.newPage(); + + const pageErrors = []; + page.on("pageerror", (err) => + pageErrors.push(err instanceof Error ? err.message : String(err)), + ); + + // Deep link: connect, switch to the Apps tab, pre-select the app tool, and + // fire "Open App". autoConnect/autoOpen must equal the session token (CSRF + // gate). Shape owned by clients/web/README.md#deep-link-auto-connect. + const url = + `${server.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` + + `&transport=http&autoConnect=${TOKEN}&openApp=${APP_TOOL}` + + `&appArgs=${encodeAppArgs({ title: "smoke:web:app" })}&autoOpen=${TOKEN}`; + + const drive = async () => { + const response = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + if (!response || !response.ok()) { + throw new Error( + `GET / returned HTTP ${response ? response.status() : "no response"}`, + ); + } + + // 1. The deep link must be accepted (not rejected by the token gate). + const status = page.locator('[data-testid="connection-status"]'); + await status.waitFor({ state: "attached", timeout: 30_000 }); + const deeplink = await status.getAttribute("data-deeplink"); + if (deeplink !== "parsed") { + throw new Error( + `deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`, + ); + } + + // 2. Connected to the test server. + await page + .locator('[data-testid="connection-status"][data-status="connected"]') + .waitFor({ state: "attached", timeout: 45_000 }); + + // 3. The widget rendered inside the sandbox and completed its handshake. + // This is the load-bearing assertion — see the header comment. + try { + await page + .locator('[data-testid="apps-form"][data-app-status="ready"]') + .waitFor({ state: "attached", timeout: 45_000 }); + } catch { + const form = page.locator('[data-testid="apps-form"]'); + const appStatus = (await form.count()) + ? await form.getAttribute("data-app-status") + : "(no apps-form)"; + const appError = (await form.count()) + ? await form.getAttribute("data-app-error") + : null; + throw new Error( + `app never reached data-app-status="ready" (last: "${appStatus}"` + + `${appError ? `, data-app-error="${appError}"` : ""}) — the sandbox proxy ` + + `or the UI-protocol bridge failed to complete`, + ); + } + }; + + // Race against launcher death so a mid-run server crash is reported as the + // real cause instead of a downstream timeout. + try { + await Promise.race([server.whenChildExits(), drive()]); + } catch (err) { + await fail( + `${err instanceof Error ? err.message : String(err)}${ + pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : "" + }`, + ); + } + + if (pageErrors.length > 0) { + await fail(`app logged uncaught error(s): ${pageErrors.join("; ")}`); + } + + console.log( + `smoke:web:app OK — connected to ${mcpUrl}, opened "${APP_TOOL}", ` + + `widget reached data-app-status="ready" through the sandbox proxy`, + ); + await shutdown(); + process.exit(0); +} catch (err) { + await fail(err instanceof Error ? err.message : String(err)); +} From 3f4f68699e4ec29ceb3a73dd993b02f954ef35ae Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:30:39 -0400 Subject: [PATCH 028/941] fix: give smoke:web:app its own port; note the App config's era in the table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #1934, both comments actionable: - smoke:web:app defaulted to 6299 — the same port smoke:web uses — while its own comment claimed the port was "distinct … so back-to-back runs can't collide". The three web smokes run sequentially in `npm run smoke`, so it passed, but a slow teardown, a TIME_WAIT socket, or a parallel run would EADDRINUSE it. Moved to 6297 (smoke:web 6299, smoke:web:browser 6298) and rewrote the comment to name the actual values rather than assert distinctness. - The showcase table tells readers to connect with Protocol Era = Modern unless noted, and mcp-app-http.json is legacy-only. That was stated in the prose below but not in the row, so a reader scanning the table would try Modern first. Marked the row. Verified: smoke:web:app passes standalone on 6297, and the full `npm run smoke` chain passes with all three web smokes on distinct ports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- README.md | 2 +- scripts/smoke-web-app.mjs | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index cb3f6c67..13792764 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | -| `mcp-app-http.json` | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | +| `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | | `mrtr-showcase-http.json` | Every MRTR preset in one server | — | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 8387476d..e2c57b51 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -83,8 +83,10 @@ const sandboxProxyPage = join( ); const HOST = "127.0.0.1"; -// Distinct from the other web smokes' ports so back-to-back runs can't collide. -const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6299"; +// Distinct from smoke:web (6299) and smoke:web:browser (6298) so a prior smoke +// whose port is still bound — slow teardown, TIME_WAIT, or a parallel run — +// can't EADDRINUSE this one. The three run back-to-back in `npm run smoke`. +const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6297"; const TOKEN = "smoke-web-app-token"; const APP_TOOL = "mcp_app_demo"; // The URL the test server announces on startup. NOT derived from the config's From 556aaca2983b9d63de01b56b85cafcdfffa642d8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:40:50 -0400 Subject: [PATCH 029/941] fix: fail smoke:web:app on async crashes, not just sync page errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's second pass (suppressed comments) caught a real gap: the smoke listened only for `pageerror`, which is the *synchronous* half of the uncaught-crash class. Its async twin — an unhandled rejection, or a failed dynamic import (this app lazy-loads chunks) — is not a `pageerror`; Chromium reports it on the console channel. So an async crash during the app-open path could leave the smoke green. smoke-web-browser.mjs already solved this; this now mirrors it exactly — same FATAL_CONSOLE pattern, same split between hard failures and diagnostics, so benign noise (a font-CDN miss, a React warning) still can't flake CI. Fatal console errors are now included both in the catch-block diagnostics and in the final assertion, which is where they were missing. Confirmed working on a passing run: a benign 409 subresource error is reported as a non-fatal diagnostic rather than failing the smoke. Full `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- scripts/smoke-web-app.mjs | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index e2c57b51..261663c0 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -89,6 +89,12 @@ const HOST = "127.0.0.1"; const PORT = process.env.SMOKE_WEB_APP_PORT ?? "6297"; const TOKEN = "smoke-web-app-token"; const APP_TOOL = "mcp_app_demo"; +// Console messages that are the async half of the uncaught-crash class (an +// unhandled rejection or a failed dynamic import). Hard failures; every other +// console error is a diagnostic, so benign font-CDN / React-warning noise can't +// flake CI. Kept identical to smoke-web-browser.mjs, which documents the +// reasoning at length. +const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; // The URL the test server announces on startup. NOT derived from the config's // port: createTestServerHttp resolves its port with findAvailablePort(), which // walks UPWARD from the configured value when it's taken — so the config port is @@ -219,10 +225,19 @@ try { browser = await loadChromium(); const page = await browser.newPage(); + // Uncaught *synchronous* page errors. Their *async* twin — an unhandled + // rejection or a failed dynamic import — is not a `pageerror`; Chromium + // reports it on the console channel instead, so both are captured and both + // are hard failures. Same split as smoke:web:browser; see FATAL_CONSOLE there. const pageErrors = []; + const consoleErrors = []; page.on("pageerror", (err) => pageErrors.push(err instanceof Error ? err.message : String(err)), ); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + const fatalConsole = () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m)); // Deep link: connect, switch to the Apps tab, pre-select the app tool, and // fire "Open App". autoConnect/autoOpen must equal the session token (CSRF @@ -285,15 +300,33 @@ try { try { await Promise.race([server.whenChildExits(), drive()]); } catch (err) { + const diagnostics = [ + ...pageErrors, + ...fatalConsole().map((m) => `console: ${m}`), + ]; await fail( `${err instanceof Error ? err.message : String(err)}${ - pageErrors.length ? ` — page errors: ${pageErrors.join("; ")}` : "" + diagnostics.length + ? ` — page diagnostics: ${diagnostics.join("; ")}` + : "" }`, ); } - if (pageErrors.length > 0) { - await fail(`app logged uncaught error(s): ${pageErrors.join("; ")}`); + // Hard failures: any uncaught sync page error, plus the console errors that + // are the async half of the same class. + const fatal = [...pageErrors, ...fatalConsole()]; + if (fatal.length > 0) { + await fail(`app logged uncaught error(s): ${fatal.join("; ")}`); + } + + // Non-fatal console errors: surface them so a real problem isn't invisible, + // without failing on benign subresource/warning noise. + const benignConsole = consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)); + if (benignConsole.length > 0) { + console.log( + `smoke:web:app note — ${benignConsole.length} non-fatal console error(s): ${benignConsole.join("; ")}`, + ); } console.log( From 5401e527e6878034d95274dd45fc1454255432dd Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 09:52:40 -0400 Subject: [PATCH 030/941] fix: handle spawn failure of the MCP test server in smoke:web:app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's third pass (suppressed comment) caught the remaining hole in the child-process handling: `spawn()` reports a failure to start via an `error` event, not `exit`. With no `error` listener Node throws it uncaught, replacing the smoke's diagnostic with a raw stack — and because `exit` never fires in that case, the readiness poll would otherwise spin for the full 30s before reporting a timeout that misattributes the cause. `close` is now listened to alongside `exit` for the same reason: it fires in cases `exit` does not. prod-web-server.mjs already documents this exact hazard for the launcher child; this brings the test-server child in line. Both new branches verified by hand: - unspawnable executable → "could not spawn the MCP test server (…): spawn /nonexistent/node-binary ENOENT", immediately, instead of an uncaught throw - missing module → "MCP test server exited early" with the loader stack, fast, with no 30s hang Full `npm run ci` green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F --- scripts/smoke-web-app.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 261663c0..73ca5f03 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -166,12 +166,25 @@ async function startMcpServer() { child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (out += d)); let exited = false; + let spawnError = null; + // A spawn failure (e.g. an unbuilt/renamed entry) emits `error`, NOT `exit` — + // and with no `error` listener Node throws it uncaught, replacing this smoke's + // diagnostic with a raw stack. `close` is listened to alongside `exit` for the + // same reason: it fires in cases `exit` does not, so a child that dies without + // an exit event can't leave the poll below spinning for the full 30s. + child.on("error", (err) => (spawnError = err)); child.on("exit", () => (exited = true)); + child.on("close", () => (exited = true)); for (let attempt = 0; attempt < 120; attempt++) { // Take the port the server actually bound, not the one we asked for. const announced = out.match(/listening at (http:\/\/\S+)/i); if (announced) return { child, url: announced[1] }; + if (spawnError) { + throw new Error( + `could not spawn the MCP test server (${composableServer}): ${spawnError.message}`, + ); + } if (exited) throw new Error(`MCP test server exited early:\n${out}`); await delay(250); } From 9644ac7fe8eb4324a6836faac4c52c813a94ece4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 6 Aug 2026 17:05:36 -0400 Subject: [PATCH 031/941] fix: prime SSE streams so Firefox resolves the events fetch Firefox does not hand a streaming `fetch()` response to JS until the first *body* byte arrives; Chromium resolves the promise as soon as the headers do. Both SSE endpoints flushed headers immediately and then stayed silent until there was something to report, which deadlocked `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that fetch *before* the MCP client sends `initialize`, so no `initialize` -> no event to report -> no body byte -> the fetch never resolves. The web UI hung on "Connecting..." forever with nothing in the UI, the console, or the node logs. Write an inert `:` SSE comment the instant each stream opens. Conforming parsers ignore a comment line, so this unblocks the read without inventing a wire event and needs no client-side protocol change. `X-Content-Type-Options: nosniff` was also proposed on the issue but does not fix it -- verified against Firefox 153, the fetch still never resolves with the header set and no body byte. The delay is not MIME sniffing. The priming write is deliberately ordered *after* each handler registers its consumer (the session event consumer; the file-watch subscriber plus `ensureWatcher()`). Callers treat the arrival of the stream's first bytes as proof they are subscribed, so priming first would hand out that proof across an `await` and drop an edit made in the gap. `/api/servers/events` gets the same treatment: its fetch was equally stuck on Firefox until the first real change event, which for an unedited `mcp.json` never comes. Its client-side reader counted any `\n\n` as a change, so the inert frame would have fired a spurious background re-fetch on every connection -- it now skips frames carrying no `event:`/`data:` field. The transport's own `parseSSE` already drops comment frames correctly and needed no change. Verified end to end in real Firefox against a live streamable-HTTP test server: before, stuck on "Connecting..." past 25s; after, initialize -> notifications/initialized -> tools/list, connected. Closes #1858 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --- .../src/test/core/react/useServers.test.tsx | 68 ++++++++ .../mcp/remote/sse-priming.test.ts | 159 ++++++++++++++++++ core/mcp/remote/node/server.ts | 37 ++++ core/react/useServers.ts | 19 ++- 4 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 clients/web/src/test/integration/mcp/remote/sse-priming.test.ts diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 0ee49df7..bb95402d 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -969,6 +969,74 @@ describe("useServers", () => { ); }); + it("ignores the backend's inert priming comment frame (#1858)", async () => { + // The backend opens every SSE stream with a `:` comment so a streaming + // fetch() resolves on Firefox at all. That frame carries no event/data + // field and must NOT be read as a change — otherwise every connection + // would fire a spurious background re-fetch on open. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + // Count list GETs rather than watching the rendered list: a spurious + // refresh fires the instant the priming frame is read, so it would race + // ahead of any later disk mutation and land the same data — invisible in + // the output but a real extra round-trip on every connection. + let listGets = 0; + let reads = 0; + let releaseSecondRead: (() => void) | undefined; + const secondRead = new Promise<void>((r) => { + releaseSecondRead = r; + }); + const encoder = new TextEncoder(); + // Must be referentially stable: the hook's SSE effect keys off `fetchFn`, + // so an inline arrow would re-subscribe (and re-refresh) every render and + // inflate the very count this test asserts on. + const fetchFn: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + // Priming comment only — no `event:` / `data:` line. + if (reads === 1) { + return { done: false, value: encoder.encode(":\n\n") }; + } + // Hold the stream open so the loop can't end and let a + // teardown-time settle hide a queued refresh. + await secondRead; + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + if (url.endsWith("/api/servers")) listGets += 1; + return h.fetchFn(url, init); + }; + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]); + + // The priming frame has been consumed (the loop is parked on read #2). + await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); + await new Promise((r) => setTimeout(r, 100)); + + // Exactly one GET: the hook's mount refresh. The comment frame added none. + expect(listGets).toBe(1); + + await act(async () => { + releaseSecondRead?.(); + await Promise.resolve(); + }); + }); + it("falls back to globalThis.fetch when no fetchFn is provided", async () => { // No fetchFn → the `doFetch = fetchFn ?? globalThis.fetch` default branch. const globalFetch = vi diff --git a/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts new file mode 100644 index 00000000..1735d027 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/sse-priming.test.ts @@ -0,0 +1,159 @@ +/** + * Regression tests for #1858 — the web UI hanging forever on "Connecting…" + * in Firefox. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives (Chromium resolves on headers). Both SSE endpoints used + * to flush headers and then stay silent until there was something to report, + * which deadlocked `/api/mcp/events`: the browser transport awaits that fetch + * before the MCP client sends `initialize`, so nothing was ever reported and + * the fetch never resolved. + * + * The fix is a priming SSE comment written the instant each stream opens. + * These tests assert the bytes land on an otherwise-idle stream — the + * behavior the browser depends on — rather than any particular payload. + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { closeHarnessServer } from "./harnessTeardown.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + storageDir: string; +} + +async function setup(): Promise<Harness> { + const storageDir = mkdtempSync(join(tmpdir(), "sse-priming-")); + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + storageDir, + initialConfig: { defaultEnvironment: {} }, + }); + return new Promise((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server, storageDir }); + }, + ); + server.on("error", reject); + }); +} + +/** + * Read the first body chunk off a streaming response, or reject if none + * arrives within `timeoutMs`. Pre-fix, an idle stream produced no chunk at + * all — which is exactly what stalled Firefox — so the timeout is the + * assertion that matters here. + */ +async function firstChunk(res: Response, timeoutMs = 3000): Promise<string> { + if (!res.body) throw new Error("response has no body"); + const reader = res.body.getReader(); + try { + const read = reader.read().then(({ value }) => { + return value ? new TextDecoder().decode(value) : ""; + }); + const timeout = new Promise<never>((_, reject) => { + setTimeout( + () => reject(new Error(`no body bytes within ${timeoutMs}ms`)), + timeoutMs, + ); + }); + return await Promise.race([read, timeout]); + } finally { + await reader.cancel().catch(() => { + /* stream already torn down */ + }); + } +} + +/** The inert SSE comment frame the backend primes each stream with. */ +const PRIMING_FRAME = ":\n\n"; + +describe("SSE streams are primed on open (#1858)", () => { + let h: Harness; + + beforeEach(async () => { + h = await setup(); + }); + + afterEach(async () => { + await closeHarnessServer(h.server); + rmSync(h.storageDir, { recursive: true, force: true }); + }); + + it("GET /api/mcp/events flushes bytes before any MCP traffic", async () => { + // A subprocess that spawns and stays alive but never speaks — the state + // the session is in between `connect` and the client's `initialize`. + // This is precisely the window in which the deadlock occurred. + const config: MCPServerConfig = { + type: "stdio", + command: process.execPath, + args: ["-e", "setInterval(() => {}, 1000);"], + }; + + const connectRes = await fetch(`${h.baseUrl}/api/mcp/connect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ config }), + }); + expect(connectRes.status).toBe(200); + const { sessionId } = (await connectRes.json()) as { sessionId: string }; + + const controller = new AbortController(); + try { + const eventsRes = await fetch( + `${h.baseUrl}/api/mcp/events?sessionId=${sessionId}`, + { signal: controller.signal }, + ); + expect(eventsRes.status).toBe(200); + + expect(await firstChunk(eventsRes)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + await fetch(`${h.baseUrl}/api/mcp/disconnect`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId }), + }).catch(() => { + /* best-effort teardown */ + }); + } + }); + + it("GET /api/servers/events flushes bytes before any file change", async () => { + const controller = new AbortController(); + try { + const res = await fetch(`${h.baseUrl}/api/servers/events`, { + signal: controller.signal, + }); + expect(res.status).toBe(200); + + expect(await firstChunk(res)).toBe(PRIMING_FRAME); + } finally { + controller.abort(); + } + }); + + it("primes with an SSE comment, which carries no event or data field", () => { + // The priming frame must be inert: a conforming parser drops it, so no + // client needs to know about it. Guards against it ever being changed + // into something a consumer would mistake for a real event. + const lines = PRIMING_FRAME.split("\n"); + expect(lines.some((l) => l.startsWith("data:"))).toBe(false); + expect(lines.some((l) => l.startsWith("event:"))).toBe(false); + expect(lines[0]).toBe(":"); + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a..6edb05d9 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -78,6 +78,27 @@ import { formatClientConfigLoadError } from "../../../client/config-parse.js"; import { envSecretField } from "../../../auth/secret-fields.js"; import { ZodError } from "zod"; +/** + * Written to every SSE stream the instant it opens, before anything else. + * + * Firefox does not hand a streaming `fetch()` response to JS until the first + * *body* byte arrives; Chromium resolves the promise as soon as the headers + * do. Both SSE endpoints here flush headers immediately and then stay silent + * until there is something to report, which deadlocks Firefox on + * `/api/mcp/events`: `RemoteClientTransport.openEventStream()` awaits that + * fetch *before* the MCP client sends `initialize`, so no `initialize` → no + * event to report → no body byte → the fetch never resolves → the web UI + * hangs on "Connecting…" forever with no error anywhere (#1858). + * + * A `:` comment line is inert per the SSE spec — conforming parsers ignore it + * — so priming with one unblocks the read without inventing a wire event. + * + * `X-Content-Type-Options: nosniff` does **not** fix this. Verified against + * Firefox 153: with the header and no body byte, the fetch still never + * resolves. The delay is not MIME sniffing. + */ +const SSE_PRIMING_COMMENT = ":\n\n"; + /** * Shape of the initial config returned by GET /api/config (defaults for client). */ @@ -785,6 +806,7 @@ export function createRemoteApp( } }); + // Prime every SSE stream the instant it opens — see SSE_PRIMING_COMMENT. app.get("/api/mcp/events", async (c) => { const sessionId = c.req.query("sessionId"); if (!sessionId) { @@ -818,6 +840,11 @@ export function createRemoteApp( return; } + // Prime only after the consumer is registered, so nothing observable + // to the client happens before this stream can actually report + // events. See SSE_PRIMING_COMMENT. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { // Client disconnected - clear event consumer const shouldCleanup = session.clearEventConsumer(); @@ -2389,6 +2416,16 @@ export function createRemoteApp( ensureWatcher(); } + // Prime the stream so the client's fetch() actually resolves — on + // Firefox it otherwise stays pending until the first real change + // event, which for an unedited `mcp.json` is never. See + // SSE_PRIMING_COMMENT. Deliberately *after* the subscriber is + // registered and the watcher started: callers treat the arrival of + // this stream's first bytes as proof they are subscribed, so priming + // first would hand out that proof across an `await`, before the + // watcher exists, and drop an edit made in the gap. + await stream.write(SSE_PRIMING_COMMENT); + stream.onAbort(() => { serverEventSubscribers.delete(send); void maybeStopWatcher(); diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 010a1e91..3a2a5002 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -82,6 +82,18 @@ async function readErrorMessage(res: Response): Promise<string> { return `HTTP ${res.status}`; } +/** + * Whether an SSE frame carries an actual event rather than only comment + * lines. The backend primes each stream with an inert `:` comment so a + * streaming `fetch()` resolves on Firefox at all (#1858); that frame must + * not be mistaken for a change notification. + */ +function isSseDataFrame(frame: string): boolean { + return frame + .split("\n") + .some((line) => line.startsWith("event:") || line.startsWith("data:")); +} + export function useServers(opts: UseServersOptions): UseServersResult { const { baseUrl, authToken, fetchFn } = opts; const doFetch = fetchFn ?? globalThis.fetch; @@ -157,6 +169,10 @@ export function useServers(opts: UseServersOptions): UseServersResult { // single background refresh per decode chunk. Two `change` // broadcasts landing in the same chunk become one re-fetch instead // of two concurrent ones whose setState order is unspecified. + // Frames carrying no `event:`/`data:` field are skipped: the backend + // opens the stream with an inert `:` comment frame so Firefox + // resolves this fetch at all (see SSE_PRIMING_COMMENT in the remote + // server), and that must not read as a change. // Cross-chunk debounce is not added: `awaitWriteFinish`'s 100ms // stability threshold already serializes external edits at the // source, and chained fetches against the same GET endpoint are @@ -169,8 +185,9 @@ export function useServers(opts: UseServersOptions): UseServersResult { let sawFrame = false; let frameEnd = buffer.indexOf("\n\n"); while (frameEnd !== -1) { + const frame = buffer.slice(0, frameEnd); buffer = buffer.slice(frameEnd + 2); - sawFrame = true; + if (isSseDataFrame(frame)) sawFrame = true; frameEnd = buffer.indexOf("\n\n"); } if (sawFrame) void refreshInternal(true); From f201032137772231a8f97171fc95e3859e70b53b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 16:48:48 -0400 Subject: [PATCH 032/941] fix: surface list-fetch failures instead of swallowing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list load that failed — a transport error, or a result the SDK's era codec rejects as invalid — reported nothing. The connect-time refresh is fired with no caller to await it (`void this.refresh()`), so its rejection became an unhandled rejection: the header said "Connected", the panel rendered an empty list indistinguishable from a server that has none, and the failed exchange showed in the Protocol panel as a clean success. Two surfaces now tell the truth: - ManagedListState records the failure as observable state (`getError` + an `errorChange` event) and still re-throws, so App's auth-recovery wrapper keeps working. The four `useManaged*` hooks expose it via a shared `useManagedListError`, and the Tools/Prompts/Resources sidebars render a `ListLoadError` alert with the reason and a Retry. - A response the client refused is attributed back to its Protocol entry (`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`), which now renders an Error status and a "Rejected by the Inspector" alert rather than the green resultType badge alone. The SDK gives no request id with a decode failure, so the id is recovered by correlation; see the comment on `markResponseRejected` for why that is exact. Also stops `listAllTools` from dropping a failing excluded-tools walk on the floor — it stays non-fatal but is logged. Closes #1953 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- clients/web/src/App.tsx | 16 ++- .../ListLoadError/ListLoadError.stories.tsx | 48 ++++++++ .../ListLoadError/ListLoadError.test.tsx | 51 +++++++++ .../elements/ListLoadError/ListLoadError.tsx | 65 +++++++++++ .../PromptControls/PromptControls.test.tsx | 25 +++++ .../groups/PromptControls/PromptControls.tsx | 8 ++ .../ProtocolEntry/ProtocolEntry.test.tsx | 54 +++++++++ .../groups/ProtocolEntry/ProtocolEntry.tsx | 29 ++++- .../ResourceControls.test.tsx | 25 +++++ .../ResourceControls/ResourceControls.tsx | 12 ++ .../groups/ToolControls/ToolControls.test.tsx | 25 +++++ .../groups/ToolControls/ToolControls.tsx | 8 ++ .../screens/PromptsScreen/PromptsScreen.tsx | 4 + .../ResourcesScreen/ResourcesScreen.tsx | 4 + .../screens/ToolsScreen/ToolsScreen.tsx | 4 + .../views/InspectorView/InspectorView.tsx | 17 +++ .../mcp/state/managedPromptsState.test.ts | 16 +++ .../managedResourceTemplatesState.test.ts | 16 +++ .../mcp/state/managedResourcesState.test.ts | 16 +++ .../core/mcp/state/managedToolsState.test.ts | 104 ++++++++++++++++++ .../core/mcp/state/messageLogState.test.ts | 65 +++++++++++ .../core/react/useManagedListError.test.tsx | 87 +++++++++++++++ .../inspectorClient-response-rejected.test.ts | 95 ++++++++++++++++ core/mcp/__tests__/fakeInspectorClient.ts | 4 + core/mcp/inspectorClient.ts | 57 +++++++++- core/mcp/inspectorClientEventTarget.ts | 9 ++ core/mcp/inspectorClientProtocol.ts | 9 ++ core/mcp/state/managedListState.ts | 77 ++++++++++++- core/mcp/state/managedPromptsState.ts | 11 +- .../state/managedResourceTemplatesState.ts | 9 +- core/mcp/state/managedResourcesState.ts | 11 +- core/mcp/state/managedToolsState.ts | 11 +- core/mcp/state/messageLogState.ts | 27 +++++ core/mcp/types.ts | 8 ++ core/react/useManagedListError.ts | 57 ++++++++++ core/react/useManagedPrompts.ts | 11 +- core/react/useManagedResourceTemplates.ts | 11 +- core/react/useManagedResources.ts | 11 +- core/react/useManagedTools.ts | 11 +- 39 files changed, 1080 insertions(+), 48 deletions(-) create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx create mode 100644 clients/web/src/components/elements/ListLoadError/ListLoadError.tsx create mode 100644 clients/web/src/test/core/react/useManagedListError.test.tsx create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts create mode 100644 core/react/useManagedListError.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 6d162c7b..509fc079 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -994,24 +994,33 @@ function App() { } = useInspectorClient(inspectorClient); const { tools: managedTools, + error: toolsLoadError, listChanged: toolsListChanged, refresh: refreshTools, clearListChanged: clearToolsListChanged, } = useManagedTools(inspectorClient, managedToolsState); const { prompts: managedPrompts, + error: promptsLoadError, listChanged: promptsListChanged, refresh: refreshPrompts, clearListChanged: clearPromptsListChanged, } = useManagedPrompts(inspectorClient, managedPromptsState); const { resources: managedResources, + error: resourcesLoadError, listChanged: resourcesListChanged, refresh: refreshResources, clearListChanged: clearResourcesListChanged, } = useManagedResources(inspectorClient, managedResourcesState); - const { resourceTemplates, refresh: refreshResourceTemplates } = - useManagedResourceTemplates(inspectorClient, managedResourceTemplatesState); + const { + resourceTemplates, + error: resourceTemplatesLoadError, + refresh: refreshResourceTemplates, + } = useManagedResourceTemplates( + inspectorClient, + managedResourceTemplatesState, + ); // Paged (paginated) list sources. When `paginatedLists` is on the managed // states skip their all-page walk and these drive the sidebar instead (#1721). const { @@ -4373,6 +4382,9 @@ function App() { toolsListChanged={toolsListChanged} promptsListChanged={promptsListChanged} resourcesListChanged={resourcesListChanged} + toolsLoadError={toolsLoadError} + promptsLoadError={promptsLoadError} + resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError} subscriptions={subscriptions} subscriptionStreamState={subscriptionStreamState} logs={logs} diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx new file mode 100644 index 00000000..de876a61 --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { ListLoadError } from "./ListLoadError"; + +const meta: Meta<typeof ListLoadError> = { + title: "Elements/ListLoadError", + component: ListLoadError, +}; + +export default meta; +type Story = StoryObj<typeof ListLoadError>; + +/** The failure this was built for: a modern server returning a list result the + * SDK codec rejects (#1953). */ +export const CodecRejection: Story = { + args: { + what: "tools", + error: new Error( + 'Invalid result for tools/list: [\n {\n "expected": "number",\n "code": "invalid_type",\n "path": [\n "ttlMs"\n ]\n }\n]', + ), + onRetry: fn(), + }, +}; + +export const TransportFailure: Story = { + args: { + what: "prompts", + error: new Error("fetch failed: ECONNREFUSED 127.0.0.1:3100"), + onRetry: fn(), + }, +}; + +/** No retry handler — the alert renders without the affordance. */ +export const WithoutRetry: Story = { + args: { + what: "resources", + error: new Error("Request timed out"), + }, +}; + +/** The resting state: no error, nothing rendered. */ +export const NoError: Story = { + args: { + what: "tools", + error: null, + onRetry: fn(), + }, +}; diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx new file mode 100644 index 00000000..7ec25174 --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ListLoadError } from "./ListLoadError"; + +describe("ListLoadError", () => { + it("renders nothing when there is no error", () => { + renderWithMantine(<ListLoadError error={null} what="tools" />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); + + it("renders nothing when the error prop is omitted", () => { + renderWithMantine(<ListLoadError what="tools" />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); + + it("names what failed to load and shows the reason verbatim", () => { + // The exact text is the diagnostic — a validation failure names the JSON + // path and what was expected, so it is rendered unabridged (#1953). + const message = 'Invalid result for tools/list: [{"path":["ttlMs"]}]'; + renderWithMantine( + <ListLoadError error={new Error(message)} what="tools" />, + ); + + expect(screen.getByText("Couldn't load tools")).toBeInTheDocument(); + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("uses the given noun so each list names itself", () => { + renderWithMantine(<ListLoadError error={new Error("x")} what="prompts" />); + expect(screen.getByText("Couldn't load prompts")).toBeInTheDocument(); + }); + + it("invokes onRetry when Retry is clicked", async () => { + const user = userEvent.setup(); + const onRetry = vi.fn(); + renderWithMantine( + <ListLoadError error={new Error("x")} what="tools" onRetry={onRetry} />, + ); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it("omits the retry affordance when no handler is given", () => { + renderWithMantine(<ListLoadError error={new Error("x")} what="tools" />); + expect( + screen.queryByRole("button", { name: "Retry" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx b/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx new file mode 100644 index 00000000..fdb4ef83 --- /dev/null +++ b/clients/web/src/components/elements/ListLoadError/ListLoadError.tsx @@ -0,0 +1,65 @@ +import { Alert, Button, Code, ScrollArea, Stack } from "@mantine/core"; + +export interface ListLoadErrorProps { + /** + * The failed load's error, or `null`/`undefined` when the last load + * succeeded (renders nothing). + */ + error?: Error | null; + /** What failed to load, for the alert title — e.g. "tools", "prompts". */ + what: string; + /** Retry the load. Omit to render the alert without a retry affordance. */ + onRetry?: () => void; +} + +// `variant="light"` + red: an error the user can act on (retry), not a fatal +// one. Sits above the list rather than replacing it — a stale list plus a +// visible "this didn't reload" beats an empty panel that looks like an answer. +const ErrorAlert = Alert.withProps({ + color: "red", + variant: "light", +}); + +// The raw message, monospaced and wrapping: these are validation failures +// (JSON paths, schema expectations) where the exact text is the diagnostic. +const ErrorMessage = Code.withProps({ + block: true, + variant: "wrapping", +}); + +// Caps the message: a schema-validation failure serializes to a dozen-plus +// lines, which would otherwise push the list itself off the sidebar. +const MessageScroll = ScrollArea.withProps({ + mah: 180, + type: "auto", +}); + +const RetryButton = Button.withProps({ + size: "xs", + variant: "light", + color: "red", + w: "fit-content", +}); + +/** + * The list panel's "couldn't load" state (#1953). + * + * A list fetch that fails — a transport error, or a result the SDK codec + * rejects as invalid for the negotiated protocol era — used to leave the panel + * empty, which is indistinguishable from a server that legitimately has no + * tools/prompts/resources. This says what happened and offers a retry. + */ +export function ListLoadError({ error, what, onRetry }: ListLoadErrorProps) { + if (!error) return null; + + return ( + <ErrorAlert title={`Couldn't load ${what}`}> + <Stack gap="xs"> + <MessageScroll> + <ErrorMessage>{error.message}</ErrorMessage> + </MessageScroll> + {onRetry && <RetryButton onClick={onRetry}>Retry</RetryButton>} + </Stack> + </ErrorAlert> + ); +} diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx index 570fe040..3fef2653 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.test.tsx @@ -160,4 +160,29 @@ describe("PromptControls", () => { expect(screen.getByText("Prompts")).toBeInTheDocument(); expect(screen.queryByText("translate")).not.toBeInTheDocument(); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <PromptControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load prompts")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders no load error by default", () => { + renderWithMantine(<PromptControls {...baseProps} />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index e99f0e61..2a2d5364 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -2,6 +2,7 @@ import { Group, ScrollArea, Stack, TextInput, Title } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Prompt } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -36,6 +37,11 @@ export interface PromptControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -48,6 +54,7 @@ export function PromptControls({ searchText = "", listChanged, onRefreshList, + loadError, pagination, onSearchChange, onSelectPrompt, @@ -75,6 +82,7 @@ export function PromptControls({ } /> <ListPaginationControls {...pagination} /> + <ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} /> <ListScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredPrompts.map((prompt) => ( diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 070e769b..0408f0b5 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -711,3 +711,57 @@ describe("ProtocolEntry — modern vocabulary", () => { }); }); }); + +// A response the server answered successfully but the CLIENT then refused — +// e.g. the SDK's era codec rejecting a 2026-07-28 list result missing +// `ttlMs`/`cacheScope`. The wire frame is a valid JSON-RPC result, so before +// #1953 this rendered as a clean success. +describe("ProtocolEntry — client-rejected response", () => { + const REASON = "Invalid result for tools/list: ttlMs required"; + + const rejectedEntry: MessageEntry = { + id: "rej-1", + timestamp: new Date("2026-07-28T10:00:00Z"), + direction: "request", + origin: "client", + message: { jsonrpc: "2.0", id: 9, method: "tools/list", params: {} }, + response: { + jsonrpc: "2.0", + id: 9, + result: { resultType: "complete", tools: [] }, + }, + clientError: REASON, + }; + + it("renders Error rather than a success status", () => { + renderWithMantine(<ProtocolEntry {...baseProps} entry={rejectedEntry} />); + expect(screen.getByText("Error")).toBeInTheDocument(); + expect(screen.queryByText("OK")).not.toBeInTheDocument(); + }); + + it("shows the Error badge even though a resultType badge is present", () => { + // The resultType normally suppresses the status badge (a modern success + // says "complete"). A rejected result must not hide behind it — the wire + // said complete, the client disagreed, and both are worth seeing. + renderWithMantine(<ProtocolEntry {...baseProps} entry={rejectedEntry} />); + expect(screen.getByText("complete")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("names the Inspector as the rejecter and gives the reason when expanded", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={rejectedEntry} isListExpanded />, + ); + expect(screen.getByText("Rejected by the Inspector")).toBeInTheDocument(); + expect(screen.getByText(REASON)).toBeInTheDocument(); + }); + + it("shows no rejection alert on an ordinary success", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={successEntry} isListExpanded />, + ); + expect( + screen.queryByText("Rejected by the Inspector"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index c7cb52f3..d5819d3d 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -135,6 +135,16 @@ const SpecErrorAlert = Alert.withProps({ icon: <RiErrorWarningLine />, }); +// The client rejected an otherwise well-formed response (#1953). Distinct from +// SpecErrorAlert: nothing is wrong with the server's JSON-RPC frame — the +// Inspector's own decoding refused the result — so the title says who rejected it. +const ClientErrorAlert = Alert.withProps({ + variant: "light", + color: "red", + icon: <RiErrorWarningLine />, + title: "Rejected by the Inspector", +}); + // Link (button-styled) that jumps to the correlated HTTP entry in the Network tab. const RevealLink = Anchor.withProps({ component: "button", @@ -213,6 +223,9 @@ function extractStatus( if (entry.direction !== "request") return "none"; if (!entry.response) return "pending"; if ("error" in entry.response) return "error"; + // The server answered with a valid result the CLIENT then refused (#1953). + // The call failed, so the entry must not read as a success. + if (entry.clientError) return "error"; return "success"; } @@ -330,11 +343,12 @@ export function ProtocolEntry({ // Suppress the redundant green "OK" when a `resultType` badge already conveys // the outcome (a modern success is `complete`/`input required`); errors and // pending have no `resultType`, so their status badge still shows. - const statusBadge = status !== "none" && !resultType && ( - <Badge color={statusColor(status)} variant="status"> - {statusLabel(status)} - </Badge> - ); + const statusBadge = status !== "none" && + (!resultType || entry.clientError) && ( + <Badge color={statusColor(status)} variant="status"> + {statusLabel(status)} + </Badge> + ); const subscriptionBadge = subscriptionId && ( <SubscriptionCluster> <SubscriptionLabel>sub</SubscriptionLabel> @@ -431,6 +445,11 @@ export function ProtocolEntry({ <Collapse in={isExpanded}> <Stack gap="sm"> <Divider /> + {entry.clientError && ( + <ClientErrorAlert> + <Text size="xs">{entry.clientError}</Text> + </ClientErrorAlert> + )} {specError && ( <McpSpecErrorAlert error={specError} diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx index c920fb32..86069af6 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.test.tsx @@ -395,4 +395,29 @@ describe("ResourceControls", () => { expect(screen.queryByText("Listening")).not.toBeInTheDocument(); }); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <ResourceControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load resources")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders no load error by default", () => { + renderWithMantine(<ResourceControls {...baseProps} />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 9a2d992f..8d4e9590 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -13,6 +13,7 @@ import type { import { isModernEra } from "../../elements/EraBadge/eraUtils"; import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -66,6 +67,11 @@ export interface ResourceControlsProps { openSections?: string[]; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls for the Resources list (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -109,6 +115,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + loadError, pagination, onSearchChange, onOpenSectionsChange, @@ -234,6 +241,11 @@ export function ResourceControls({ <ListToggle compact={!allExpanded} onToggle={handleToggleList} /> </TightRow> <ListPaginationControls {...pagination} /> + <ListLoadError + error={loadError} + what="resources" + onRetry={onRefreshList} + /> {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, so `.withProps({ multiple: true, ... })` loses its JSX call signature (same tooling limit as Box). */} diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index c586e12c..1ce40d1f 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -183,4 +183,29 @@ describe("ToolControls", () => { ); expect(screen.getByText("invalid_header_tool")).toBeInTheDocument(); }); + + // A failed load is rendered above the list instead of leaving the panel + // empty, which is indistinguishable from a server that has none (#1953). + it("renders a failed load above the list and retries via onRefreshList", async () => { + const user = userEvent.setup(); + const onRefreshList = vi.fn(); + renderWithMantine( + <ToolControls + {...baseProps} + loadError={new Error("codec said no")} + onRefreshList={onRefreshList} + />, + ); + + expect(screen.getByText("Couldn't load tools")).toBeInTheDocument(); + expect(screen.getByText("codec said no")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(onRefreshList).toHaveBeenCalledTimes(1); + }); + + it("renders no load error by default", () => { + renderWithMantine(<ToolControls {...baseProps} />); + expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index db0a049a..1806b0c3 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -14,6 +14,7 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Tool } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; +import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -32,6 +33,11 @@ export interface ToolControlsProps { searchText?: string; listChanged: boolean; onRefreshList: () => void; + /** + * A failed list load, surfaced above the list instead of leaving the panel + * empty (which reads as "this server has none") (#1953). + */ + loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; @@ -109,6 +115,7 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + loadError, pagination, onSearchChange, onSelectTool, @@ -146,6 +153,7 @@ export function ToolControls({ } /> <ListPaginationControls {...pagination} /> + <ListLoadError error={loadError} what="tools" onRetry={onRefreshList} /> <SidebarScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredTools.map((tool) => ( diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index de43f488..c599ed47 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -33,6 +33,8 @@ export interface PromptsScreenProps { completionsSupported?: boolean; onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onGetPrompt: (name: string, args: Record<string, string>) => void; @@ -136,6 +138,7 @@ export function PromptsScreen({ completionsSupported, onUiChange, onRefreshList, + loadError, pagination, onGetPrompt, onCopyMessages, @@ -273,6 +276,7 @@ export function PromptsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index bf07fb43..a94f9169 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -57,6 +57,8 @@ export interface ResourcesScreenProps { subscriptionsSupported?: boolean; onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onReadResource: (uri: string) => void; @@ -170,6 +172,7 @@ export function ResourcesScreen({ subscriptionsSupported = true, onUiChange, onRefreshList, + loadError, pagination, onReadResource, onSubscribeResource, @@ -325,6 +328,7 @@ export function ResourcesScreen({ openSections={openSections} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onOpenSectionsChange={(value) => diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index 51f55c9a..f6dd4ed3 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -60,6 +60,8 @@ export interface ToolsScreenProps { modernTasks?: boolean; onUiChange: (next: ToolsUiState) => void; onRefreshList: () => void; + /** A failed list load, rendered above the sidebar list (#1953). */ + loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; onCallTool: ( @@ -152,6 +154,7 @@ export function ToolsScreen({ modernTasks = false, onUiChange, onRefreshList, + loadError, pagination, onCallTool, onCancelCall, @@ -192,6 +195,7 @@ export function ToolsScreen({ searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} + loadError={loadError} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectTool={handleSelectTool} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 06d1098b..6d162fbf 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -435,6 +435,17 @@ export interface InspectorViewProps { toolsListChanged: boolean; promptsListChanged: boolean; resourcesListChanged: boolean; + + // Last list-load failure per screen, sourced from the same managed-state + // layer. Rendered above the sidebar list so a failed load (including the + // connect-time one) can't read as "this server has none" (#1953). + toolsLoadError?: Error | null; + promptsLoadError?: Error | null; + /** + * The Resources sidebar lists resources AND templates behind a single + * Refresh, so App passes whichever of the two loads failed. + */ + resourcesLoadError?: Error | null; logs: LogEntryData[]; tasks: Task[]; progressByTaskId?: Record<string, TaskProgress>; @@ -627,6 +638,9 @@ export function InspectorView({ toolsListChanged, promptsListChanged, resourcesListChanged, + toolsLoadError, + promptsLoadError, + resourcesLoadError, subscriptions, subscriptionStreamState, logs, @@ -1354,6 +1368,7 @@ export function InspectorView({ callState={toolCallState} ui={toolsUi} listChanged={toolsListChanged} + loadError={toolsLoadError} serverSupportsTaskToolCalls={serverSupportsTaskToolCalls} modernTasks={ protocolEra === "modern" && @@ -1393,6 +1408,7 @@ export function InspectorView({ getPromptState={getPromptState} ui={promptsUi} listChanged={promptsListChanged} + loadError={promptsLoadError} completionsSupported={completionsSupported} onUiChange={onPromptsUiChange} onRefreshList={onRefreshPrompts} @@ -1412,6 +1428,7 @@ export function InspectorView({ readState={readResourceState} ui={resourcesUi} listChanged={resourcesListChanged} + loadError={resourcesLoadError} completionsSupported={completionsSupported} subscriptionsSupported={subscriptionsSupported} onUiChange={onResourcesUiChange} diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index 17c1113f..fe33943e 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -285,6 +285,22 @@ describe("ManagedPromptsState", () => { }); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to prompts/list", async () => { + const boom = new Error("nope"); + client.setStatus("connected"); + client.listAllPrompts.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "prompts/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index c4850760..bc865d7a 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -266,6 +266,22 @@ describe("ManagedResourceTemplatesState", () => { expect(state.getResourceTemplates()).toEqual([]); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to resources/templates/list", async () => { + const boom = new Error("nope"); + client.setStatus("connected"); + client.listAllResourceTemplates.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "resources/templates/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index 3e82467c..1af9d8e7 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -292,6 +292,22 @@ describe("ManagedResourcesState", () => { }); }); + // The base class owns the error plumbing (covered in managedToolsState); this + // pins THIS list's method string, which is what attributes a failure to the + // right Protocol entry (#1953). + it("records a failed load and attributes it to resources/list", async () => { + const boom = new Error("nope"); + client.setStatus("connected"); + client.listAllResources.mockRejectedValueOnce(boom); + + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "resources/list", + "nope", + ); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 449c1b24..8e9e45db 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -412,6 +412,110 @@ describe("ManagedToolsState", () => { }); }); + // A failed load used to vanish: the connect-time refresh is fired with no + // caller to await it, so its rejection became an unhandled rejection and the + // panel just rendered an empty list — indistinguishable from a server with no + // tools (#1953). + describe("load errors", () => { + const boom = new Error("Invalid result for tools/list: ttlMs required"); + + function waitForError(state: ManagedToolsState): Promise<Error | null> { + return waitForChangeEvent(state, "errorChange"); + } + + it("starts with no error", () => { + expect(state.getError()).toBeNull(); + }); + + it("records a failed refresh as state AND re-throws", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + + // The rejection still propagates: App's auth-recovery wrapper keys off it + // to detect a 401 and start a re-authorization. + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + }); + + it("dispatches errorChange with the error", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + await expect(state.refresh()).rejects.toThrow(boom); + expect(await changed).toBe(boom); + }); + + it("wraps a non-Error rejection", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce("just a string"); + await expect(state.refresh()).rejects.toBe("just a string"); + expect(state.getError()).toBeInstanceOf(Error); + expect(state.getError()?.message).toBe("just a string"); + }); + + it("attributes the failure to its Protocol entry", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + boom.message, + ); + }); + + it("clears the error once a refresh succeeds", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const cleared = waitForError(state); + client.queueToolPages({ tools: [tool("a")] }); + await state.refresh(); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("keeps the connect-time failure in state instead of rejecting unobserved", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + await client.connect(); + expect(await changed).toBe(boom); + }); + + it("keeps the auto-refresh failure in state instead of rejecting unobserved", async () => { + client.setServerSettings(AUTO_REFRESH_SETTINGS); + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + const changed = waitForError(state); + client.dispatchTypedEvent("toolsListChanged"); + expect(await changed).toBe(boom); + }); + + it("clears the error on disconnect so it can't outlive its session", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const cleared = waitForError(state); + client.setStatus("disconnected"); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("does not re-dispatch when the same error is recorded twice", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValue(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + let fired = 0; + state.addEventListener("errorChange", () => { + fired++; + }); + await expect(state.refresh()).rejects.toThrow(boom); + expect(fired).toBe(0); + }); + }); + it("destroy is idempotent", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); diff --git a/clients/web/src/test/core/mcp/state/messageLogState.test.ts b/clients/web/src/test/core/mcp/state/messageLogState.test.ts index b1088ddf..f6c11db3 100644 --- a/clients/web/src/test/core/mcp/state/messageLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/messageLogState.test.ts @@ -234,4 +234,69 @@ describe("MessageLogState", () => { state.destroy(); expect(() => state.destroy()).not.toThrow(); }); + + // A response the SERVER answered successfully but the CLIENT then refused + // (the SDK codec rejecting the result). The frame is a valid JSON-RPC + // result, so without the annotation the entry renders as a clean success + // (#1953). + describe("responseRejected", () => { + const REASON = "Invalid result for tools/list: ttlMs required"; + + it("annotates the request entry the response was folded into", () => { + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + + const seen: MessageEntry[] = []; + state.addEventListener("message", (e) => seen.push(e.detail)); + client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON }); + + expect(state.getMessages()[0]?.clientError).toBe(REASON); + expect(seen).toHaveLength(1); + }); + + it("annotates a standalone response entry with no matching request", () => { + client.dispatchTypedEvent("message", responseEntry(7)); + client.dispatchTypedEvent("responseRejected", { id: 7, reason: REASON }); + + expect(state.getMessages()[0]?.clientError).toBe(REASON); + }); + + it("skips a request still awaiting its response", () => { + // Same JSON-RPC id reused after the first exchange completed: the + // pending one isn't what was rejected. + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + client.dispatchTypedEvent("message", requestEntry(1)); + + client.dispatchTypedEvent("responseRejected", { id: 1, reason: REASON }); + + const [completed, pending] = state.getMessages(); + expect(pending?.clientError).toBeUndefined(); + expect(completed?.clientError).toBe(REASON); + }); + + it("ignores an id that matches no entry", () => { + client.dispatchTypedEvent("message", requestEntry(1)); + client.dispatchTypedEvent("message", responseEntry(1)); + + let fired = 0; + state.addEventListener("messagesChange", () => { + fired++; + }); + client.dispatchTypedEvent("responseRejected", { id: 99, reason: REASON }); + + expect(fired).toBe(0); + expect(state.getMessages()[0]?.clientError).toBeUndefined(); + }); + + it("never annotates a notification", () => { + client.dispatchTypedEvent("message", notificationEntry()); + client.dispatchTypedEvent("responseRejected", { + id: 1, + reason: REASON, + }); + + expect(state.getMessages()[0]?.clientError).toBeUndefined(); + }); + }); }); diff --git a/clients/web/src/test/core/react/useManagedListError.test.tsx b/clients/web/src/test/core/react/useManagedListError.test.tsx new file mode 100644 index 00000000..7863fe32 --- /dev/null +++ b/clients/web/src/test/core/react/useManagedListError.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; +import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState"; +import { useManagedListError } from "@inspector/core/react/useManagedListError"; + +// The shared subscription behind the four `useManaged*` hooks' `error` field +// (#1953). Exercised through ManagedToolsState — any managed list would do, +// since the error lives entirely in the shared base. +describe("useManagedListError", () => { + let client: FakeInspectorClient; + let state: ManagedToolsState; + const boom = new Error("Invalid result for tools/list: ttlMs required"); + + beforeEach(() => { + client = new FakeInspectorClient({ + status: "connected", + capabilities: { tools: {} }, + }); + state = new ManagedToolsState(client, 0); + }); + + it("returns null when there is no state", () => { + const { result } = renderHook(() => useManagedListError(null)); + expect(result.current).toBeNull(); + }); + + it("returns null before any load fails", () => { + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBeNull(); + }); + + it("seeds from an error the state already holds", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBe(boom); + }); + + it("updates when the state dispatches errorChange", async () => { + const { result } = renderHook(() => useManagedListError(state)); + + client.listAllTools.mockRejectedValueOnce(boom); + await act(async () => { + await expect(state.refresh()).rejects.toThrow(boom); + }); + expect(result.current).toBe(boom); + }); + + it("clears when a later load succeeds", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + const { result } = renderHook(() => useManagedListError(state)); + expect(result.current).toBe(boom); + + await act(async () => { + await state.refresh(); + }); + expect(result.current).toBeNull(); + }); + + it("resets to null when the state becomes null", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const { result, rerender } = renderHook( + ({ s }: { s: ManagedToolsState | null }) => useManagedListError(s), + { initialProps: { s: state as ManagedToolsState | null } }, + ); + expect(result.current).toBe(boom); + + rerender({ s: null }); + expect(result.current).toBeNull(); + }); + + it("unsubscribes on unmount", async () => { + const { unmount } = renderHook(() => useManagedListError(state)); + unmount(); + + client.listAllTools.mockRejectedValueOnce(boom); + // No act() wrapper: a listener still attached would warn about an update + // outside act, and the assertion below would be the only other signal. + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts new file mode 100644 index 00000000..4f0b1c51 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-response-rejected.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { MessageLogState } from "@inspector/core/mcp/state/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { getTestMcpServerCommand } from "@modelcontextprotocol/inspector-test-server"; +import type { MessageEntry } from "@inspector/core/mcp/types.js"; + +/** + * `markResponseRejected` correlation (#1953). + * + * A response the server answered successfully but the client then refused (the + * SDK codec rejecting the result for the negotiated era) carries no request id + * in the SDK's error, so the client recovers it from the last response received + * for that method. These tests drive a REAL connection so the correlation runs + * against real JSON-RPC ids assigned by the SDK, not hand-built fixtures. + */ +describe("InspectorClient.markResponseRejected", () => { + let client: InspectorClient | null = null; + let log: MessageLogState | null = null; + + beforeEach(async () => { + const serverCommand = getTestMcpServerCommand(); + client = new InspectorClient( + { + type: "stdio", + command: serverCommand.command, + args: serverCommand.args, + }, + { environment: { transport: createTransportNode } }, + ); + log = new MessageLogState(client); + await client.connect(); + }); + + afterEach(async () => { + log?.destroy(); + log = null; + try { + await client?.disconnect(); + } catch { + // Ignore teardown failures — the assertion already ran. + } + client = null; + }); + + function entriesFor(method: string): MessageEntry[] { + return (log?.getMessages() ?? []).filter( + (entry) => "method" in entry.message && entry.message.method === method, + ); + } + + it("annotates the entry for the method's most recent response", async () => { + await client!.listTools(); + const before = entriesFor("tools/list"); + expect(before).toHaveLength(1); + expect(before[0]?.clientError).toBeUndefined(); + + client!.markResponseRejected("tools/list", "ttlMs required"); + + expect(entriesFor("tools/list")[0]?.clientError).toBe("ttlMs required"); + }); + + it("annotates only the latest call, leaving earlier ones untouched", async () => { + await client!.listTools(); + await client!.listTools(); + const entries = entriesFor("tools/list"); + expect(entries).toHaveLength(2); + + client!.markResponseRejected("tools/list", "second one failed"); + + expect(entries[0]?.clientError).toBeUndefined(); + expect(entries[1]?.clientError).toBe("second one failed"); + }); + + it("does not cross method boundaries", async () => { + await client!.listTools(); + await client!.listPrompts(); + + client!.markResponseRejected("tools/list", "tools failed"); + + expect(entriesFor("tools/list")[0]?.clientError).toBe("tools failed"); + expect(entriesFor("prompts/list")[0]?.clientError).toBeUndefined(); + }); + + it("is a no-op for a method nothing has answered", () => { + // No throw, and nothing annotated — a list that never reached the wire + // (e.g. a capability-gated one) must not mislabel some other entry. + expect(() => + client!.markResponseRejected("resources/list", "never happened"), + ).not.toThrow(); + expect((log?.getMessages() ?? []).some((entry) => entry.clientError)).toBe( + false, + ); + }); +}); diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index c9fc9cdb..00e3ed16 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -133,6 +133,10 @@ export class FakeInspectorClient return this.tasksExtensionNegotiated; } + // Attributes a failed load back to its Protocol entry (#1953). A `vi.fn` so + // tests can assert the method name and reason a failing refresh reported. + markResponseRejected = vi.fn((_method: string, _reason: string) => {}); + // Aggregate variants used by the managed state stores on refresh: drain ALL // queued pages (mimicking the SDK's all-page walk) and return the flattened // list. The `options` (incl. `cacheMode`) is recorded by the `vi.fn` so tests diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e63b1473..b0c14f94 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -358,6 +358,13 @@ export class InspectorClient extends InspectorClientEventTarget { private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; + // Correlation for `markResponseRejected` (#1953): the method of each + // outbound request still awaiting a response, and — once one is answered — + // the id of the most recently answered request per method. Entries are + // dropped as responses arrive, so this holds at most one id per method + // rather than growing with the session. + private outboundRequestMethods = new Map<string | number, string>(); + private lastAnsweredRequestByMethod = new Map<string, string | number>(); /** True when the cached transport was built with an OAuth authProvider attached. */ private transportHasAuthProvider = false; /** Dedupes concurrent ambient auth challenges (reason + scopes). */ @@ -786,6 +793,9 @@ export class InspectorClient extends InspectorClientEventTarget { private createMessageTrackingCallbacks(): MessageTrackingCallbacks { return { trackRequest: (message: JSONRPCRequest, origin: MessageOrigin) => { + if (origin === "client") { + this.outboundRequestMethods.set(message.id, message.method); + } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -799,6 +809,19 @@ export class InspectorClient extends InspectorClientEventTarget { message: JSONRPCResultResponse | JSONRPCErrorResponse, origin: MessageOrigin, ) => { + // A response to one of OUR requests closes that id's correlation entry + // and becomes the method's most recent answer (#1953). The transport + // only tracks responses that carry an id, but the JSON-RPC types leave + // it optional for an error frame the server couldn't attribute — such a + // frame answers no specific request, so it is skipped. + const responseId = message.id; + if (origin === "server" && responseId !== undefined) { + const method = this.outboundRequestMethods.get(responseId); + this.outboundRequestMethods.delete(responseId); + if (method !== undefined) { + this.lastAnsweredRequestByMethod.set(method, responseId); + } + } const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -3080,8 +3103,17 @@ export class InspectorClient extends InspectorClientEventTarget { // reflect the current wire truth. Accepted for a debugging tool where the // list is small and correctness of "why did this tool vanish" matters more // than the extra request; it's a no-op (no round trip) on legacy/stdio. - // Kept best-effort: an error here must never fail the tools list itself. - await this.refreshExcludedTools(options?.metadata).catch(() => {}); + // Kept best-effort: an error here must never fail the tools list itself — + // but it is logged rather than dropped on the floor, so a failing + // excluded-tools walk is diagnosable instead of silently leaving the + // "Excluded (SEP-2243)" section empty and looking like a clean server + // (#1953). + await this.refreshExcludedTools(options?.metadata).catch((err: unknown) => { + this.logger.warn( + { err }, + "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete", + ); + }); return { tools: [...response.tools] }; } @@ -3095,6 +3127,27 @@ export class InspectorClient extends InspectorClientEventTarget { return this.isModernEra() && this.getServerType() !== "stdio"; } + /** + * Mark the response that most recently answered `method` as rejected by the + * client, so its Protocol entry shows why instead of rendering as a clean + * success (#1953). + * + * The SDK gives no request id with a decode failure — `SdkError` carries the + * method and nothing else — so the id is recovered by correlation: the last + * response received for that method. That is exact rather than approximate, + * because the SDK rejects synchronously while decoding the response (inside + * the transport's `onmessage`) and the caller's `catch` runs in the very next + * microtask. Delivering another response for the same method in that window + * would take a macrotask (a socket read), which cannot interleave there. + * + * A no-op when nothing has answered `method` this session. + */ + markResponseRejected(method: string, reason: string): void { + const id = this.lastAnsweredRequestByMethod.get(method); + if (id === undefined) return; + this.dispatchTypedEvent("responseRejected", { id, reason }); + } + /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ getExcludedTools(): ExcludedTool[] { return this.excludedTools; diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index a4ccafe0..d4edee16 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -71,6 +71,15 @@ export interface InspectorClientEventMap { /** `server/discover` result on a probed/pinned connect; undefined on legacy. */ discoverResultChange: DiscoverResult | undefined; message: MessageEntry; + /** + * A response the client REJECTED after it was logged — the server answered, + * but the SDK's era codec refused the result (e.g. a 2026-07-28 `tools/list` + * missing `ttlMs`/`cacheScope`). The wire frame is a valid JSON-RPC result, + * so the Protocol entry would otherwise render as a clean success; this + * carries the reason so it can be marked instead (#1953). `id` is the + * JSON-RPC id of the rejected response. + */ + responseRejected: { id: string | number; reason: string }; stderrLog: StderrLogEntry; fetchRequest: FetchRequestEntry; /** Fired when an in-flight fetch's response body is read asynchronously. */ diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 3bed5223..6a5322ea 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -105,6 +105,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { * and the modern task store's poll-based refresh. */ isTasksExtensionNegotiated(): boolean; + /** + * Mark the response that most recently answered `method` as rejected by the + * client, so its Protocol entry shows the reason instead of rendering as a + * clean success (#1953). Optional so existing test doubles satisfy the + * interface without implementing it; see `InspectorClient` for how the id is + * correlated and why that correlation is exact. + */ + markResponseRejected?(method: string, reason: string): void; + // Aggregate (all-page) list methods used by the managed state stores on // refresh. Unlike the single-page methods above, these route through the // SDK's cache-aware high-level verbs so `cacheMode` ('use' | 'refresh' | diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index 4f6160e1..62b9fdfa 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -29,12 +29,29 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; -/** Every managed-list event map carries the list-changed indicator event. */ +/** + * Every managed-list event map carries the list-changed indicator event and the + * last-fetch error (#1953). + */ export interface ManagedListEventMap { + /** + * Fires when the "list changed since last refresh" flag flips. True when + * this list's `list_changed` notification arrives (auto-refresh off), false + * once the user refreshes or the connection drops. Drives the sidebar + * list-changed indicator (#1402). + */ listChangedChange: boolean; + /** The last fetch's failure, or `null` once a fetch succeeds. */ + errorChange: Error | null; } export interface ManagedListConfig<T, M extends ManagedListEventMap> { + /** + * The JSON-RPC method this list pages (e.g. "tools/list"). Used to attribute + * a failed load back to its Protocol entry — see + * `InspectorClientProtocol.markResponseRejected` (#1953). + */ + listMethod: string; /** The `*Change` event this manager dispatches (e.g. "toolsChange"). */ changeEvent: keyof M; /** The client notification that signals the list changed. */ @@ -81,6 +98,11 @@ export abstract class ManagedListState< private unsubscribe: (() => void) | null = null; private _metadata: Record<string, string> | undefined = undefined; private listChanged = false; + // The last fetch's failure, kept as observable state so a load that fails + // (a transport error, or a result the SDK codec rejects as invalid) is + // rendered by the list panel instead of vanishing into an unhandled + // rejection — which read as "this server has no tools" (#1953). + private error: Error | null = null; private readonly config: ManagedListConfig<T, M>; // Debounce a burst of `list_changed` notifications into a single // refresh (or one indicator light) once it settles. @@ -112,7 +134,11 @@ export abstract class ManagedListState< ) { return; } - void this.refresh(); + // The connect-time load has no caller to await it, so its rejection is + // caught here rather than left to become an unhandled rejection. This is + // not a swallow: `refresh` has already recorded the failure via + // `setError`, and the list panel renders it (#1953). + void this.refresh().catch(() => {}); }; const onListChanged = (): void => { // Debounce: collapse a burst of notifications into one settled action @@ -133,6 +159,9 @@ export abstract class ManagedListState< this.items = []; this.dispatchChange(); this.setListChanged(false); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load tools" must not outlive it into the next connect. + this.setError(null); } }; this.client.addEventListener("connect", onConnect); @@ -187,8 +216,10 @@ export abstract class ManagedListState< if (!skipAggregate && settings?.autoRefreshOnListChanged) { // A `list_changed` means the prior list is stale, so bypass any // cached entry (`cacheMode: "refresh"`) and re-store the fresh - // aggregate. - await this.refresh(undefined, "refresh"); + // aggregate. Like the connect-time load this has no caller to await + // it, so a failure is caught here — already recorded by `setError` + // and rendered by the panel (#1953). + await this.refresh(undefined, "refresh").catch(() => {}); } else if (this.config.supportsIndicator) { this.setListChanged(true); } @@ -236,6 +267,20 @@ export abstract class ManagedListState< this.emit("listChangedChange", value); } + /** The last fetch's failure, or `null` when the last fetch succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.emit("errorChange", value); + } + setMetadata(metadata?: Record<string, string>): void { this._metadata = metadata; } @@ -245,12 +290,34 @@ export abstract class ManagedListState< * this fetch: `undefined` (the connect-time load) uses the default `'use'`; * a user-initiated or auto refresh passes `'refresh'` to force a * cache-bypassing round trip and re-store the fresh aggregate. + * + * A failure is recorded as observable state (`getError`) AND re-thrown: the + * state drives the panel's error rendering, while the rejection is what the + * caller's auth-recovery wrapper keys off to detect a 401 and start a + * re-authorization. Callers with nobody to await them (the connect-time load, + * the `list_changed` auto-refresh) catch it explicitly (#1953). */ async refresh( metadata?: Record<string, string>, cacheMode?: CacheMode, ): Promise<T[]> { - const next = await this.fetchItems(metadata, cacheMode); + let next: T[] | null; + try { + next = await this.fetchItems(metadata, cacheMode); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + this.setError(error); + // Attribute the failure to the response it came from, so the Protocol + // entry stops rendering a rejected result as a clean success (#1953). + // Must happen in this catch, while the correlation window the client + // documents is still valid. + this.client?.markResponseRejected?.( + this.config.listMethod, + error.message, + ); + throw err; + } + this.setError(null); // `null` means not connected — leave the current list untouched. if (next === null) return this.getItems(); this.applyItems(next); diff --git a/core/mcp/state/managedPromptsState.ts b/core/mcp/state/managedPromptsState.ts index a12fcd4a..93a106ae 100644 --- a/core/mcp/state/managedPromptsState.ts +++ b/core/mcp/state/managedPromptsState.ts @@ -8,17 +8,11 @@ import type { Prompt } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedPromptsStateEventMap { +export interface ManagedPromptsStateEventMap extends ManagedListEventMap { promptsChange: Prompt[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `prompts/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the sidebar list-changed - * indicator (#1402). - */ - listChangedChange: boolean; } export class ManagedPromptsState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedPromptsState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "prompts/list", changeEvent: "promptsChange", listChangedEvent: "promptsListChanged", capabilityKey: "prompts", diff --git a/core/mcp/state/managedResourceTemplatesState.ts b/core/mcp/state/managedResourceTemplatesState.ts index aceedd4f..fe5f44f2 100644 --- a/core/mcp/state/managedResourceTemplatesState.ts +++ b/core/mcp/state/managedResourceTemplatesState.ts @@ -15,15 +15,11 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedResourceTemplatesStateEventMap { +export interface ManagedResourceTemplatesStateEventMap extends ManagedListEventMap { resourceTemplatesChange: ResourceTemplate[]; - /** - * Carried only to satisfy the ManagedListState base; templates have no - * indicator, so this never fires. - */ - listChangedChange: boolean; } export class ManagedResourceTemplatesState extends ManagedListState< @@ -35,6 +31,7 @@ export class ManagedResourceTemplatesState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "resources/templates/list", changeEvent: "resourceTemplatesChange", listChangedEvent: "resourceTemplatesListChanged", // Templates are gated on the broader `resources` capability. diff --git a/core/mcp/state/managedResourcesState.ts b/core/mcp/state/managedResourcesState.ts index f00076e4..1d693bd8 100644 --- a/core/mcp/state/managedResourcesState.ts +++ b/core/mcp/state/managedResourcesState.ts @@ -8,17 +8,11 @@ import type { Resource } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedResourcesStateEventMap { +export interface ManagedResourcesStateEventMap extends ManagedListEventMap { resourcesChange: Resource[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `resources/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the list-changed indicator - * (#1402). - */ - listChangedChange: boolean; } export class ManagedResourcesState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedResourcesState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "resources/list", changeEvent: "resourcesChange", listChangedEvent: "resourcesListChanged", capabilityKey: "resources", diff --git a/core/mcp/state/managedToolsState.ts b/core/mcp/state/managedToolsState.ts index 03a87d59..10ed14bd 100644 --- a/core/mcp/state/managedToolsState.ts +++ b/core/mcp/state/managedToolsState.ts @@ -8,17 +8,11 @@ import type { Tool } from "@modelcontextprotocol/client"; import { ManagedListState, DEFAULT_LIST_CHANGED_DEBOUNCE_MS, + type ManagedListEventMap, } from "./managedListState.js"; -export interface ManagedToolsStateEventMap { +export interface ManagedToolsStateEventMap extends ManagedListEventMap { toolsChange: Tool[]; - /** - * Fires when the "list changed since last refresh" flag flips. True when a - * `tools/list_changed` arrives (auto-refresh off), false once the user - * refreshes or the connection drops. Drives the sidebar list-changed - * indicator (#1402). - */ - listChangedChange: boolean; } export class ManagedToolsState extends ManagedListState< @@ -30,6 +24,7 @@ export class ManagedToolsState extends ManagedListState< debounceMs = DEFAULT_LIST_CHANGED_DEBOUNCE_MS, ) { super(client, { + listMethod: "tools/list", changeEvent: "toolsChange", listChangedEvent: "toolsListChanged", capabilityKey: "tools", diff --git a/core/mcp/state/messageLogState.ts b/core/mcp/state/messageLogState.ts index 9355741e..02a27224 100644 --- a/core/mcp/state/messageLogState.ts +++ b/core/mcp/state/messageLogState.ts @@ -98,6 +98,31 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> { pushEntry(entry); }; + // A response the client rejected after the fact (the wire frame was valid + // JSON-RPC, but the SDK codec refused the result). Annotate the entry that + // carries that JSON-RPC id so it stops rendering as a clean success — the + // request entry when the response was folded into it, otherwise the + // standalone response entry. Searched newest-first: ids are unique within + // a session, and the failing exchange is by construction a recent one. + const onResponseRejected = ( + event: TypedEventGeneric<InspectorClientEventMap, "responseRejected">, + ): void => { + const { id, reason } = event.detail; + for (let i = this.messages.length - 1; i >= 0; i--) { + const entry = this.messages[i]!; + const entryId = (entry.message as { id?: string | number }).id; + if (entryId !== id) continue; + if (entry.direction === "notification") continue; + // A request entry with no response yet isn't the one being rejected — + // keep looking for the standalone response frame. + if (entry.direction === "request" && !entry.response) continue; + entry.clientError = reason; + this.dispatchTypedEvent("message", entry); + this.dispatchTypedEvent("messagesChange", this.getMessages()); + return; + } + }; + const onStatusChange = (): void => { if (isTerminalStatus(this.client?.getStatus())) { this.messages = []; @@ -106,10 +131,12 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> { } }; this.client.addEventListener("message", onMessage); + this.client.addEventListener("responseRejected", onResponseRejected); this.client.addEventListener("statusChange", onStatusChange); this.unsubscribe = () => { if (this.client) { this.client.removeEventListener("message", onMessage); + this.client.removeEventListener("responseRejected", onResponseRejected); this.client.removeEventListener("statusChange", onStatusChange); } this.client = null; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b95c65a7..52840af8 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -285,6 +285,14 @@ export interface MessageEntry { | JSONRPCErrorResponse; response?: JSONRPCResultResponse | JSONRPCErrorResponse; duration?: number; // Time between request and response in ms + /** + * Why the CLIENT rejected an otherwise well-formed response — e.g. the SDK's + * era codec refusing a 2026-07-28 `tools/list` result that omits + * `ttlMs`/`cacheScope`. Distinct from a JSON-RPC `error` response: the server + * answered successfully and the wire frame is valid, so without this the + * entry renders as a clean success even though the call failed (#1953). + */ + clientError?: string; } /** Method name for any MessageEntry traffic, plus synthetic "response" for result/error entries. */ diff --git a/core/react/useManagedListError.ts b/core/react/useManagedListError.ts new file mode 100644 index 00000000..22fe307f --- /dev/null +++ b/core/react/useManagedListError.ts @@ -0,0 +1,57 @@ +import { useState, useEffect } from "react"; +import type { ManagedListEventMap } from "../mcp/state/managedListState.js"; +import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; + +/** + * The slice of a managed list state this hook needs. Declared structurally + * rather than as `ManagedListState<T, M>` so the four list hooks can share it + * without threading their item type through — the error is the same shape for + * all of them. + */ +export interface ManagedListErrorSource { + getError(): Error | null; + addEventListener( + type: "errorChange", + listener: ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => void, + ): void; + removeEventListener( + type: "errorChange", + listener: ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => void, + ): void; +} + +/** + * Subscribe to a managed list state's last-fetch error (#1953). + * + * Shared by the four `useManaged*` hooks so a list load that fails — including + * the connect-time one, which has no caller to await it — reaches the UI + * instead of only the console. `null` means the last fetch succeeded. + */ +export function useManagedListError( + state: ManagedListErrorSource | null, +): Error | null { + const [error, setError] = useState<Error | null>(state?.getError() ?? null); + + useEffect(() => { + if (!state) { + setError(null); + return; + } + setError(state.getError()); + const onErrorChange = ( + event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + ) => { + setError(event.detail); + }; + state.addEventListener("errorChange", onErrorChange); + return () => { + state.removeEventListener("errorChange", onErrorChange); + }; + }, [state]); + + return error; +} diff --git a/core/react/useManagedPrompts.ts b/core/react/useManagedPrompts.ts index db9b94e1..e0de0bb1 100644 --- a/core/react/useManagedPrompts.ts +++ b/core/react/useManagedPrompts.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedPromptsState.js"; import type { Prompt } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedPromptsResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; prompts: Prompt[]; /** True when a `prompts/list_changed` arrived since the last user refresh. */ listChanged: boolean; @@ -65,6 +72,8 @@ export function useManagedPrompts( }; }, [managedPromptsState]); + const error = useManagedListError(managedPromptsState); + const refresh = useCallback(async (): Promise<Prompt[]> => { if (!managedPromptsState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -85,5 +94,5 @@ export function useManagedPrompts( managedPromptsState?.clearListChanged(); }, [managedPromptsState]); - return { prompts, listChanged, refresh, clearListChanged }; + return { prompts, error, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedResourceTemplates.ts b/core/react/useManagedResourceTemplates.ts index 3851769e..40a6b994 100644 --- a/core/react/useManagedResourceTemplates.ts +++ b/core/react/useManagedResourceTemplates.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedResourceTemplatesState.js"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedResourceTemplatesResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; resourceTemplates: ResourceTemplate[]; refresh: () => Promise<ResourceTemplate[]>; } @@ -50,6 +57,8 @@ export function useManagedResourceTemplates( }; }, [managedResourceTemplatesState]); + const error = useManagedListError(managedResourceTemplatesState); + const refresh = useCallback(async (): Promise<ResourceTemplate[]> => { if (!managedResourceTemplatesState || !client) return []; // A user-initiated refresh forces a cache-bypassing round trip @@ -63,5 +72,5 @@ export function useManagedResourceTemplates( return next; }, [client, managedResourceTemplatesState]); - return { resourceTemplates, refresh }; + return { resourceTemplates, error, refresh }; } diff --git a/core/react/useManagedResources.ts b/core/react/useManagedResources.ts index 8fe03172..12786062 100644 --- a/core/react/useManagedResources.ts +++ b/core/react/useManagedResources.ts @@ -6,8 +6,15 @@ import type { } from "../mcp/state/managedResourcesState.js"; import type { Resource } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedResourcesResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; resources: Resource[]; /** * True when a `resources/list_changed` arrived since the last user refresh. @@ -76,6 +83,8 @@ export function useManagedResources( }; }, [managedResourcesState]); + const error = useManagedListError(managedResourcesState); + const refresh = useCallback(async (): Promise<Resource[]> => { if (!managedResourcesState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -96,5 +105,5 @@ export function useManagedResources( managedResourcesState?.clearListChanged(); }, [managedResourcesState]); - return { resources, listChanged, refresh, clearListChanged }; + return { resources, error, listChanged, refresh, clearListChanged }; } diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index fa71452d..d7ed1c22 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -4,8 +4,15 @@ import type { ManagedToolsState } from "../mcp/state/managedToolsState.js"; import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js"; import type { Tool } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useManagedListError } from "./useManagedListError.js"; export interface UseManagedToolsResult { + /** + * The last fetch's failure (transport error, or a result the SDK codec + * rejected), or `null` when it succeeded. Includes the connect-time load, + * whose failure has no caller to surface it (#1953). + */ + error: Error | null; tools: Tool[]; /** True when a `tools/list_changed` arrived since the last user refresh. */ listChanged: boolean; @@ -65,6 +72,8 @@ export function useManagedTools( }; }, [managedToolsState]); + const error = useManagedListError(managedToolsState); + const refresh = useCallback(async (): Promise<Tool[]> => { if (!managedToolsState || !client) return []; // A user-initiated refresh acknowledges the change — clear the indicator @@ -85,5 +94,5 @@ export function useManagedTools( managedToolsState?.clearListChanged(); }, [managedToolsState]); - return { tools, listChanged, refresh, clearListChanged }; + return { tools, error, listChanged, refresh, clearListChanged }; } From 1e427c05f3b4c032889045930e8a920153af555e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:11:34 -0400 Subject: [PATCH 033/941] fix: mark a rejected standalone response entry as an error too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractStatus returned none for any non-request entry, so a clientError annotation that landed on a standalone response frame — the fallback messageLogState uses when no request entry was there to fold into, e.g. a trimmed log or a reconnect boundary — rendered with no status badge at all. The clientError check now runs before the request-only lifecycle. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../ProtocolEntry/ProtocolEntry.test.tsx | 35 +++++++++++++++++++ .../groups/ProtocolEntry/ProtocolEntry.tsx | 10 ++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index 0408f0b5..45d658c4 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -756,6 +756,41 @@ describe("ProtocolEntry — client-rejected response", () => { expect(screen.getByText(REASON)).toBeInTheDocument(); }); + // messageLogState falls back to the standalone response frame when no request + // entry was there to fold into (a trimmed log, or a reconnect boundary). That + // entry is not `direction: "request"`, so the request-only status lifecycle + // would have rendered it without any badge at all. + const rejectedStandaloneResponse: MessageEntry = { + id: "rej-standalone", + timestamp: new Date("2026-07-28T10:00:00Z"), + direction: "response", + origin: "server", + message: { + jsonrpc: "2.0", + id: 11, + result: { resultType: "complete", tools: [] }, + }, + clientError: REASON, + }; + + it("renders Error on a rejected standalone response entry", () => { + renderWithMantine( + <ProtocolEntry {...baseProps} entry={rejectedStandaloneResponse} />, + ); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("still renders no status badge on an unrejected standalone response", () => { + const clean: MessageEntry = { + ...rejectedStandaloneResponse, + clientError: undefined, + }; + renderWithMantine(<ProtocolEntry {...baseProps} entry={clean} />); + expect(screen.queryByText("Error")).not.toBeInTheDocument(); + expect(screen.queryByText("OK")).not.toBeInTheDocument(); + expect(screen.queryByText("Pending")).not.toBeInTheDocument(); + }); + it("shows no rejection alert on an ordinary success", () => { renderWithMantine( <ProtocolEntry {...baseProps} entry={successEntry} isListExpanded />, diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index d5819d3d..73edda6c 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -220,12 +220,16 @@ function extractResourceUri(entry: MessageEntry): string | undefined { function extractStatus( entry: MessageEntry, ): "success" | "error" | "pending" | "none" { + // A response the CLIENT refused is an error whichever entry carries it, so + // this is checked BEFORE the request-only lifecycle below (#1953). + // messageLogState annotates the request entry when the response was folded + // into one, but falls back to the standalone response frame when there was + // no matching request (a trimmed log, or a reconnect boundary) — and that + // entry would otherwise fall straight through to "none" and render no badge. + if (entry.clientError) return "error"; if (entry.direction !== "request") return "none"; if (!entry.response) return "pending"; if ("error" in entry.response) return "error"; - // The server answered with a valid result the CLIENT then refused (#1953). - // The call failed, so the entry must not read as a success. - if (entry.clientError) return "error"; return "success"; } From fad848037617195fb86a7ec88eb8582816b5a48c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:26:36 -0400 Subject: [PATCH 034/941] fix: only attribute a decode rejection to a Protocol entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markResponseRejected was called for ANY refresh failure. The id it recovers is "the last response received for this method", which is the failing exchange only when a response actually arrived and was refused while decoding. A transport drop, a timeout, or an abort produces no response frame at all — the last-answered id then still points at an EARLIER successful call, so marking it stamped "Rejected by the Inspector" onto an exchange that worked. That is the same class of lie this issue exists to remove. A server-sent JSON-RPC error is excluded for a different reason: the id would be right, but the failure is the server's and its entry already renders as an error from the error frame. Gated on SdkErrorCode.InvalidResult / UnsupportedResultType — decisions the client made about a frame in hand. The failure is still recorded as list state either way; only the Protocol attribution is gated. Caught in Copilot's review of #1953. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../mcp/state/managedPromptsState.test.ts | 3 +- .../managedResourceTemplatesState.test.ts | 3 +- .../mcp/state/managedResourcesState.test.ts | 3 +- .../core/mcp/state/managedToolsState.test.ts | 77 +++++++++++++++++-- core/mcp/state/managedListState.ts | 46 +++++++++-- 5 files changed, 117 insertions(+), 15 deletions(-) diff --git a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts index fe33943e..939af2ef 100644 --- a/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedPromptsState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Prompt } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedPromptsState } from "@inspector/core/mcp/state/managedPromptsState"; @@ -289,7 +290,7 @@ describe("ManagedPromptsState", () => { // pins THIS list's method string, which is what attributes a failure to the // right Protocol entry (#1953). it("records a failed load and attributes it to prompts/list", async () => { - const boom = new Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllPrompts.mockRejectedValueOnce(boom); diff --git a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts index bc865d7a..be88cd5f 100644 --- a/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourceTemplatesState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedResourceTemplatesState } from "@inspector/core/mcp/state/managedResourceTemplatesState"; @@ -270,7 +271,7 @@ describe("ManagedResourceTemplatesState", () => { // pins THIS list's method string, which is what attributes a failure to the // right Protocol entry (#1953). it("records a failed load and attributes it to resources/templates/list", async () => { - const boom = new Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllResourceTemplates.mockRejectedValueOnce(boom); diff --git a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts index 1af9d8e7..7944f3a4 100644 --- a/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedResourcesState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Resource } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedResourcesState } from "@inspector/core/mcp/state/managedResourcesState"; @@ -296,7 +297,7 @@ describe("ManagedResourcesState", () => { // pins THIS list's method string, which is what attributes a failure to the // right Protocol entry (#1953). it("records a failed load and attributes it to resources/list", async () => { - const boom = new Error("nope"); + const boom = new SdkError(SdkErrorCode.InvalidResult, "nope"); client.setStatus("connected"); client.listAllResources.mockRejectedValueOnce(boom); diff --git a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts index 8e9e45db..8455dd74 100644 --- a/clients/web/src/test/core/mcp/state/managedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/managedToolsState.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import type { Tool } from "@modelcontextprotocol/client"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState"; @@ -453,14 +454,76 @@ describe("ManagedToolsState", () => { expect(state.getError()?.message).toBe("just a string"); }); - it("attributes the failure to its Protocol entry", async () => { - client.setStatus("connected"); - client.listAllTools.mockRejectedValueOnce(boom); - await expect(state.refresh()).rejects.toThrow(boom); - expect(client.markResponseRejected).toHaveBeenCalledWith( - "tools/list", - boom.message, + // Only a decode rejection may be attributed to a Protocol entry. The id is + // recovered as "the last response for this method", which is the failing + // exchange ONLY when a response actually arrived and was refused — see + // isClientDecodeRejection. + describe("Protocol-entry attribution", () => { + const decodeRejection = new SdkError( + SdkErrorCode.InvalidResult, + "Invalid result for tools/list: ttlMs required", ); + + it("attributes a decode rejection to its Protocol entry", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(decodeRejection); + await expect(state.refresh()).rejects.toThrow(decodeRejection); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + decodeRejection.message, + ); + }); + + it("attributes an unsupported resultType too", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.UnsupportedResultType, "unknown type"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).toHaveBeenCalledWith( + "tools/list", + "unknown type", + ); + }); + + // The regression this guard exists for: no response frame arrived, so the + // last-answered id still points at an EARLIER successful call. Marking it + // would stamp "Rejected by the Inspector" onto an exchange that worked. + it("does NOT attribute a transport failure", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + it("does NOT attribute a request timeout", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce( + new SdkError(SdkErrorCode.RequestTimeout, "Request timed out"), + ); + await expect(state.refresh()).rejects.toThrow(); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + // A real response, so the id would be right — but the failure is the + // server's, and its entry already renders as an error from the error + // frame. Blaming the Inspector would misattribute the cause. + it("does NOT attribute a plain (non-SDK) error", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); + + it("still records every failure as state, attributed or not", async () => { + client.setStatus("connected"); + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + expect(state.getError()).toBe(boom); + expect(client.markResponseRejected).not.toHaveBeenCalled(); + }); }); it("clears the error once a refresh succeeds", async () => { diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index 62b9fdfa..ea5128b5 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -17,6 +17,7 @@ import type { CacheMode, ServerCapabilities, } from "@modelcontextprotocol/client"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; @@ -29,6 +30,38 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; +/** + * Whether a failed fetch is the CLIENT refusing a response it received, rather + * than the request never producing one. + * + * This gates `markResponseRejected`, and the distinction is load-bearing. That + * correlation recovers the request id as "the last response received for this + * method", which is only the failing exchange when a response actually just + * arrived and was refused while decoding. A transport drop, a timeout, or an + * aborted request produces no response frame at all — the last-answered id then + * still points at some EARLIER, successful call, and marking it would stamp + * "Rejected by the Inspector" onto an exchange that succeeded. That is the very + * class of lie this issue exists to remove, so it must not be traded for + * another one. + * + * A server-sent JSON-RPC error is excluded for a different reason: it is a real + * response, so the id would be right, but the failure is the server's. Its + * entry already renders as an error from the error frame itself, and blaming + * the Inspector for it would misattribute the cause. + * + * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed + * validation for the negotiated era"; `UnsupportedResultType` is its sibling + * for a `resultType` the codec has no handling for. Both are decisions the + * client made about a frame in hand. + */ +function isClientDecodeRejection(err: unknown): boolean { + return ( + SdkError.isInstance(err) && + (err.code === SdkErrorCode.InvalidResult || + err.code === SdkErrorCode.UnsupportedResultType) + ); +} + /** * Every managed-list event map carries the list-changed indicator event and the * last-fetch error (#1953). @@ -310,11 +343,14 @@ export abstract class ManagedListState< // Attribute the failure to the response it came from, so the Protocol // entry stops rendering a rejected result as a clean success (#1953). // Must happen in this catch, while the correlation window the client - // documents is still valid. - this.client?.markResponseRejected?.( - this.config.listMethod, - error.message, - ); + // documents is still valid — and ONLY for a decode rejection, see + // `isClientDecodeRejection`. + if (isClientDecodeRejection(err)) { + this.client?.markResponseRejected?.( + this.config.listMethod, + error.message, + ); + } throw err; } this.setError(null); From a6f233eb6a95a6d95c5bb2e0f929ace96ee41a3b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 8 Aug 2026 17:41:27 -0400 Subject: [PATCH 035/941] fix: read the list error via useSyncExternalStore; clear correlation maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes from Copilot's review of #1953. useManagedListError re-synced React state from the `state` prop inside a useEffect, which renders one frame carrying the PREVIOUS store's error after `state` changes (switching servers) before the effect corrects it — the pattern AGENTS.md forbids. useSyncExternalStore reads the snapshot during render, so a store swap lands in the same frame, and it also closes the window where an error recorded between render and subscribe was missed. The snapshot returns the stored Error instance (or null), so it is referentially stable as the API requires. The `markResponseRejected` correlation maps were never cleared. Ids of requests that never got a response — a timeout, a dropped connection — are the only entries trackResponse can't remove, so they accumulated across reconnects. Cleared on the start-clean connect path, per the convention documented on resetSessionState (one route out, `onerror` with no `onclose`, tears down nothing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../core/react/useManagedListError.test.tsx | 25 ++++++++++ core/mcp/inspectorClient.ts | 11 +++++ core/react/useManagedListError.ts | 49 ++++++++++++------- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/core/react/useManagedListError.test.tsx b/clients/web/src/test/core/react/useManagedListError.test.tsx index 7863fe32..1eac0afd 100644 --- a/clients/web/src/test/core/react/useManagedListError.test.tsx +++ b/clients/web/src/test/core/react/useManagedListError.test.tsx @@ -84,4 +84,29 @@ describe("useManagedListError", () => { await expect(state.refresh()).rejects.toThrow(boom); expect(state.getError()).toBe(boom); }); + + // The useState+useEffect subscribe pattern would render one frame carrying + // the PREVIOUS store's error here, before the effect re-synced. With + // useSyncExternalStore the snapshot is read during render, so the swap lands + // in the same frame — asserted immediately after rerender, with no waitFor + // and no act() flush, which is what makes it a regression test rather than a + // restatement of eventual consistency. + it("reflects a store swap in the same frame", async () => { + client.listAllTools.mockRejectedValueOnce(boom); + await expect(state.refresh()).rejects.toThrow(boom); + + const other = new ManagedToolsState(client, 0); + + const { result, rerender } = renderHook( + ({ s }: { s: ManagedToolsState }) => useManagedListError(s), + { initialProps: { s: state } }, + ); + expect(result.current).toBe(boom); + + rerender({ s: other }); + expect(result.current).toBeNull(); + + rerender({ s: state }); + expect(result.current).toBe(boom); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0c14f94..8e268477 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1581,6 +1581,17 @@ export class InspectorClient extends InspectorClientEventTarget { this.clearReceiverTasks(); this.resetSubscriptionStream(); this.cancelledTaskIds.clear(); + // Correlation data is per-session: JSON-RPC ids don't survive it, and + // MessageLogState drops its entries on disconnect, so anything left here + // could only point at an entry that no longer exists. Clearing also + // releases the ids of requests that never got a response (a timeout, a + // dropped connection) — the only entries `trackResponse` can't remove, so + // without this they accumulate across reconnects. Cleared here on the + // start-clean path rather than in `disconnect()` for the reason documented + // above: one route out (`onerror` with no `onclose`) tears down nothing + // (#1953). + this.outboundRequestMethods.clear(); + this.lastAnsweredRequestByMethod.clear(); for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } diff --git a/core/react/useManagedListError.ts b/core/react/useManagedListError.ts index 22fe307f..64438e52 100644 --- a/core/react/useManagedListError.ts +++ b/core/react/useManagedListError.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useCallback, useSyncExternalStore } from "react"; import type { ManagedListEventMap } from "../mcp/state/managedListState.js"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; @@ -30,28 +30,39 @@ export interface ManagedListErrorSource { * Shared by the four `useManaged*` hooks so a list load that fails — including * the connect-time one, which has no caller to await it — reaches the UI * instead of only the console. `null` means the last fetch succeeded. + * + * Built on `useSyncExternalStore` rather than the `useState` + `useEffect` + * subscribe pattern the sibling hooks use. Re-syncing state from the `state` + * prop inside an effect would render one frame carrying the PREVIOUS store's + * error after `state` changes (switching servers) before the effect corrects + * it — the "don't derive state from props in an effect" rule in AGENTS.md. + * `useSyncExternalStore` has no such window: the snapshot is read during + * render, so a store swap is reflected in the same frame, and it also closes + * the gap where an error recorded between render and subscribe would be missed. + * + * The snapshot must be referentially stable across reads that mean "no change", + * which it is: it returns the stored `Error` instance itself (or `null`), never + * a fresh object. */ export function useManagedListError( state: ManagedListErrorSource | null, ): Error | null { - const [error, setError] = useState<Error | null>(state?.getError() ?? null); + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!state) return () => {}; + const listener = () => onStoreChange(); + state.addEventListener("errorChange", listener); + return () => { + state.removeEventListener("errorChange", listener); + }; + }, + [state], + ); - useEffect(() => { - if (!state) { - setError(null); - return; - } - setError(state.getError()); - const onErrorChange = ( - event: TypedEventGeneric<ManagedListEventMap, "errorChange">, - ) => { - setError(event.detail); - }; - state.addEventListener("errorChange", onErrorChange); - return () => { - state.removeEventListener("errorChange", onErrorChange); - }; - }, [state]); + const getSnapshot = useCallback(() => state?.getError() ?? null, [state]); - return error; + // Server snapshot: same read. The stores are browser/Node runtime objects + // with no SSR path, and passing the same getter keeps hydration consistent + // rather than throwing on a server render. + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } From bb219dc0014bd68b92a7e6a0c0242153a416b80a Mon Sep 17 00:00:00 2001 From: Cliff Hall <olahungerford@gmail.com> Date: Sat, 8 Aug 2026 20:30:16 -0400 Subject: [PATCH 036/941] fix: construct AsyncEntry inside the try so keychain degradation engages (#1848) (#1945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: construct AsyncEntry inside the try so keychain degradation engages `KeyringSecretStore` built its `AsyncEntry` outside the `try` in `get`, `set`, and `delete`. `AsyncEntry::new` performs the platform-store setup (on Linux, the Secret Service connect with a keyutils fallback) and throws when no backend is reachable, so the documented degradation contract never engaged for a construction-time failure: the raw keyring error escaped, 500ing every `GET /api/servers` on a box without a Secret Service (the published container, which has no D-Bus session). `expectedSecretFields` always includes the OAuth slot, so `rehydrateConfig` constructs an entry for every server — the default seeded catalog was enough to 500 the first list load, before any server was added. And because the escaping error wasn't a `KeychainUnavailableError`, it bypassed both the routes' 503 translation and the `migratePlaintextSecrets` skip branch, yielding a generic 500 instead of the actionable message. Moving construction inside the existing `try` restores the contract for that failure mode: `get` returns null, `delete` no-ops, and `set` is the only operation that throws — as `KeychainUnavailableError`, which is what the 503 translation and the migration skip both match on. The test stub's constructor could not fail, which is why the coverage gate never saw the gap. Adds a `constructorThrows` hook alongside the existing method-level `failures` flags and covers all three methods plus `deleteAllForServer` under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F * test: pin why the OAuth secret field is unconditional `expectedSecretFields` always includes the OAuth slot, and #1848's report frames that as why the 500 fired so broadly — which invites a follow-up that skips the keychain read for servers with "no OAuth config". That change would lose data. `extractSecretsFromStored` deletes the `oauth` block outright when `clientSecret` was its only property, so such a server carries NO marker on disk that a secret exists — the keychain is the sole record, and the unconditional slot is what finds it again. Gating the read on a disk-visible `oauth` block would silently stop rehydrating exactly that shape. The existing tests do fail if the slot is made conditional (verified by applying the change: 4 red), but only under names that explain nothing about the consequence — `always lists the OAuth slot first` reads like a tautology worth updating rather than a trap. Add a round-trip case that states the consequence, so the next reader sees the field is load-bearing rather than defensive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../web/src/test/core/mcp/serverList.test.ts | 30 ++++++++++ .../auth/node/secret-store.test.ts | 56 +++++++++++++++++++ core/auth/node/secret-store.ts | 41 ++++++++------ 3 files changed, 111 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index d6118fbd..c5eee523 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1076,6 +1076,36 @@ describe("expectedSecretFields", () => { }), ).toEqual([SECRET_FIELD_OAUTH_CLIENT_SECRET]); }); + + it("keeps a secret-only OAuth config reachable across the disk round trip", () => { + // The OAuth slot is unconditional, and that is load-bearing rather + // than merely defensive — skipping the keychain read for servers with + // "no OAuth config" is a tempting optimization that would silently + // stop rehydrating exactly this shape. + // + // `extractSecretsFromStored` deletes the `oauth` block outright when + // `clientSecret` was its only property, so the on-disk entry carries + // NO marker that a secret exists — the keychain is the only record. + // The unconditional slot is what finds it again. + const { stripped, secrets } = extractSecretsFromStored({ + type: "streamable-http", + url: "https://x.test", + oauth: { clientSecret: "shh" }, + }); + expect(stripped).not.toHaveProperty("oauth"); + + // What a GET does: enumerate fields from the *stripped* on-disk shape, + // read those from the keychain, merge back. + const fields = expectedSecretFields(stripped); + expect(fields).toContain(SECRET_FIELD_OAUTH_CLIENT_SECRET); + + const fromKeychain = Object.fromEntries( + fields.filter((f) => f in secrets).map((f) => [f, secrets[f]!]), + ); + expect(mergeSecretsIntoStored(stripped, fromKeychain).oauth).toEqual({ + clientSecret: "shh", + }); + }); }); describe("enterpriseManaged oauth settings", () => { diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index f118081c..29130bfa 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -24,6 +24,12 @@ const keyringMocks = vi.hoisted(() => { deleteThrows: false, findThrows: false, deleteThrowsNoEntry: false, + // `AsyncEntry::new` itself does the platform-store setup and throws + // when no backend is reachable (a container with no D-Bus session, + // Linux without libsecret). Modelling it is what catches #1848: a + // stub that can only fail per-method leaves the construction path + // untested, so an escaping constructor error looks green here. + constructorThrows: false, }; const credentials = (): Array<{ account: string; password: string }> => { const out: Array<{ account: string; password: string }> = []; @@ -35,6 +41,11 @@ const keyringMocks = vi.hoisted(() => { class AsyncEntry { private readonly key: string; constructor(_service: string, username: string) { + if (failures.constructorThrows) { + throw new Error( + "Couldn't access platform storage: PermissionDenied (constructor)", + ); + } this.key = username; } async getPassword(): Promise<string | undefined> { @@ -215,6 +226,7 @@ describe("KeyringSecretStore (mocked native bindings)", () => { keyringMocks.failures.deleteThrows = false; keyringMocks.failures.findThrows = false; keyringMocks.failures.deleteThrowsNoEntry = false; + keyringMocks.failures.constructorThrows = false; store = new KeyringSecretStore(); }); @@ -329,4 +341,48 @@ describe("KeyringSecretStore (mocked native bindings)", () => { expect((err as Error).message).toMatch(/libsecret/); } }); + + describe("keychain unreachable at AsyncEntry construction (#1848)", () => { + // `AsyncEntry::new` — not just its methods — throws when no platform + // store is reachable. The degradation contract must hold identically + // for that failure mode; constructing outside the `try` let the raw + // keyring error escape and 500 `GET /api/servers` before any secret + // was touched. + beforeEach(() => { + keyringMocks.failures.constructorThrows = true; + }); + + it("get returns null", async () => { + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + }); + + it("set throws KeychainUnavailableError, not the raw keyring error", async () => { + // The typed error is what the routes translate to a 503 and what + // `migratePlaintextSecrets` matches on to skip migration. + await expect( + store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"), + ).rejects.toBeInstanceOf(KeychainUnavailableError); + await expect( + store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v"), + ).rejects.toThrow(/Couldn't access platform storage/); + }); + + it("delete silently no-ops", async () => { + await expect( + store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).resolves.toBeUndefined(); + }); + + it("deleteAllForServer no-ops even when the credential sweep finds entries", async () => { + // findCredentialsAsync can succeed while per-entry construction + // fails; the sweep must still resolve rather than escape. + keyringMocks.failures.constructorThrows = false; + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); + keyringMocks.failures.constructorThrows = true; + + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + }); }); diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index dbc07d79..a1adb232 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -80,18 +80,27 @@ export interface SecretStore { * a real value and must round-trip). * * **Availability behavior.** When the keychain is unavailable (the - * typical case is Linux without libsecret / gnome-keyring), `set` is - * the only operation that throws `KeychainUnavailableError` — that's - * the moment where data would actually be lost. `get` returns `null` - * (as if no entry existed) and the destructive operations silently - * no-op (there's nothing to delete anyway). This keeps non-secret - * flows working on a stock CI runner / minimal Linux box; the user - * only hits a hard error when they actually try to save a secret. + * typical case is Linux without libsecret / gnome-keyring, or a + * container with no D-Bus session), `set` is the only operation that + * throws `KeychainUnavailableError` — that's the moment where data + * would actually be lost. `get` returns `null` (as if no entry + * existed) and the destructive operations silently no-op (there's + * nothing to delete anyway). This keeps non-secret flows working on a + * stock CI runner / minimal Linux box; the user only hits a hard error + * when they actually try to save a secret. + * + * The `AsyncEntry` construction is deliberately **inside** each + * method's `try`: `AsyncEntry::new` performs the platform-store setup + * (on Linux, the Secret Service connect with a keyutils fallback) and + * throws when no backend is reachable. Constructing it outside the + * `try` let that raw error escape, 500ing every `GET /api/servers` + * before any secret was involved — the contract above only holds if + * construction failures are funneled through the same handlers (#1848). */ export class KeyringSecretStore implements SecretStore { async get(serverId: string, field: string): Promise<string | null> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); const v = await entry.getPassword(); return v ?? null; } catch { @@ -104,8 +113,8 @@ export class KeyringSecretStore implements SecretStore { } async set(serverId: string, field: string, value: string): Promise<void> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); await entry.setPassword(value); } catch (err) { // The only operation that hard-fails — if we can't persist the @@ -116,17 +125,17 @@ export class KeyringSecretStore implements SecretStore { } async delete(serverId: string, field: string): Promise<void> { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); try { + const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); await entry.deleteCredential(); } catch { - // Both reasons for a throw collapse to the same desired outcome + // Every reason for a throw collapses to the same desired outcome // ("the entry isn't there anymore"): `deleteCredential` raises - // NoEntry for a missing credential, and the native binding - // raises a runtime error when the keychain itself is unavailable. - // We treat both as success — there's no value to lose either - // way, and `set` is the operation that hard-fails when the - // keychain is actually down. + // NoEntry for a missing credential, and both the constructor and + // the native binding raise a runtime error when the keychain + // itself is unavailable. We treat all of them as success — there's + // no value to lose either way, and `set` is the operation that + // hard-fails when the keychain is actually down. } } From a09f2dcf1da18c77bb04f6fb25b5e3ed7467e136 Mon Sep 17 00:00:00 2001 From: Cliff Hall <olahungerford@gmail.com> Date: Sat, 8 Aug 2026 21:15:20 -0400 Subject: [PATCH 037/941] fix: load @napi-rs/keyring lazily so an unsupported platform degrades instead of crashing (#1905) (#1948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: load @napi-rs/keyring lazily so an unsupported platform degrades `@napi-rs/keyring` ships one prebuilt binary per platform triple and throws on import where it has none. On Android / Termux there is no `@napi-rs/keyring-android-arm64`, so the static top-level import in `core/auth/node/secret-store.ts` threw during module evaluation and the Inspector exited at startup with "Cannot find native binding" — before any of the keychain-unavailable handling in that same file could run. Replaces the static import with a cached dynamic one. The outcome is cached, not just the module, so a box without a binary doesn't re-attempt (and re-throw) resolution on every secret operation — `expectedSecretFields` means that would otherwise be once per server per `GET /api/servers` — and so `set` can name the underlying cause in its `KeychainUnavailableError`. An unloadable package now folds into the same degradation contract as an unreachable keychain: `get` returns null, `delete` and `deleteAllForServer` no-op, and `set` throws `KeychainUnavailableError` (not double-wrapped — the load failure is already typed by the time the catch sees it). The class doc comment now enumerates all three ways "unavailable" can arrive, since each escaped the contract at some point. The shared `vi.mock` stub can't express this — it models a keyring that loads — so the new tests build on `vi.resetModules()` + `vi.doMock` with a throwing factory and re-import the module, covering the load failure, each method under it, and the load-once caching. Verified the dynamic import survives bundling in all three clients (cli, tui, and the web runner) rather than being inlined back to a static one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F * fix: steer a missing native binding to a reinstall hint `KeychainUnavailableError` gave the Linux libsecret / gnome-keyring advice for every cause, including the one where the keychain is fine and the @napi-rs/keyring platform package is what's missing — an unsupported triple (#1905) or npm's optional-deps bug dropping it on a supported one (npm/cli#4828, the Windows report on #1852). Detect the loader's "Cannot find native binding" phrasing and point those users at a reinstall / npx-cache clear instead. Ported from the superseded #1943, which fixed this alongside the same lazy-load change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: name a bad keyring namespace instead of "is not a constructor" The lazy import accepted whatever the resolution produced. The named exports arrive through CJS interop, so a resolution that stopped yielding them — a default-only export upstream, a bundler changing interop, a platform where named-export detection fails — would hand us `undefined`, and `new mod.AsyncEntry(...)` would throw a TypeError inside the very try that implements graceful degradation. Shape-check the resolved module and treat a bad shape as unavailable. Scope, because it is narrower than it first appears: this does NOT prevent a bad shape from emptying the secret list. `get` returns null either way — that is its read-tolerance contract, and the TypeError lands in the same catch a dead keychain does. What changes is the diagnosis. Without the check the only signal is `set` reporting "keyring.mod.AsyncEntry is not a constructor", which reads like an Inspector bug; with it, `set` names the real problem once, at the load boundary, in the same actionable 503 as every other unavailability. Catching the silent-empty-list case itself would need a round-trip against the unmocked package, which nothing in the suite does today. The member access sits inside the try because reading a missing export is not always a harmless undefined — a Proxy-backed namespace can throw, as vitest's module mocks do — and letting that escape would reject the cached promise, which is otherwise guaranteed to always resolve. Of the four new tests only the cause-message one fails without the guard; the rest pin the surrounding contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * docs: correct the test header's count of unavailability entry points The shape check added a fourth way "unavailable" can arrive, and the enumeration in secret-store.ts was updated to match, but the test file's header still said three — so it read as an exhaustive list that silently omitted the case a whole describe below it covers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: give the wrong-shape cause its own remediation hint The shape check added a cause that fell through to the libsecret advice — so a packaging mismatch told the user to install a keyring daemon, which cannot help, and on Windows or macOS points somewhere that does not exist. That is the same wrong-advice bug the native-binding branch was added to fix, reintroduced by the guard itself. Hint selection moves into `hintFor`, where libsecret is the explicit fallback rather than the default, and the shape failure becomes a distinct `KeyringModuleShapeError` matched by type — it is our own error, so there is no reason to re-parse text we wrote (the native-binding branch matches a string only because that message comes from napi-rs). Also retitles the wrong-shape test: it claimed the guard avoids "returning null secrets", but `get` returns null either way by the read-tolerance contract. The title now says what actually differs — `set` hard-fails — matching the correction already made in the comments. Both raised by Copilot review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 * fix: give both shape-check failure paths the packaging hint The shape check can fail two ways — the members are absent, or reading them throws — and only the first carried KeyringModuleShapeError. The catch path returned the raw error, so a Proxy-backed namespace throwing on access fell through to the libsecret advice: the same wrong-remedy bug as the previous two commits, one branch further along. Both paths now carry the type. KeyringModuleShapeError takes a detail string plus a standard `cause`, so the throwing path reads "its exports could not be read: <original>" and keeps the underlying failure legible while still earning the packaging hint. Three instances of one mistake in a row (a new cause added without deciding which remediation it inherits) says the shape was wrong, not just the branch: making libsecret an explicit fallback in `hintFor` did not stop me adding a second exit that skipped it. The tests now pin the invariant directly — one asserts both construction paths produce the same hint — so the next cause that forgets is a failing test rather than a reviewer's catch. Raised by Copilot review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- clients/web/server/vite-base-config.ts | 4 +- .../auth/node/secret-store.test.ts | 315 +++++++++++++++++- core/auth/node/secret-store.ts | 236 +++++++++++-- 3 files changed, 525 insertions(+), 30 deletions(-) diff --git a/clients/web/server/vite-base-config.ts b/clients/web/server/vite-base-config.ts index aa4b149f..c351f473 100644 --- a/clients/web/server/vite-base-config.ts +++ b/clients/web/server/vite-base-config.ts @@ -28,7 +28,9 @@ const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [ "which", // `@napi-rs/keyring` is loaded only inside // `core/auth/node/secret-store.ts` from the Hono `/api/servers` - // handlers. It's a native-binding package (no browser code path) so + // handlers — and lazily even there, via a cached dynamic import, so + // an unsupported platform degrades instead of crashing at startup + // (#1905). It's a native-binding package (no browser code path) so // excluding it keeps Vite's dep scanner from chasing into the // platform-specific binaries during dev startup. "@napi-rs/keyring", diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index 29130bfa..2d66858e 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -7,8 +7,16 @@ * so the suite stubs the native side and asserts the tolerance contract * (`get` returns null on failure, destructive ops no-op, `set` is the * one operation that hard-fails with `KeychainUnavailableError`). + * + * The contract has four entry points for "unavailable" and the suite + * covers all four: the operation throwing, `AsyncEntry`'s constructor + * throwing (#1848), the package failing to load at all (#1905), and the + * package loading but exposing the wrong shape. The last two can't use + * the shared stub — one needs the *import* to reject, the other needs it + * to resolve to a namespace the stub can't express — so each lives in + * its own describe built on `vi.resetModules()`. */ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // The mock must be hoisted above the `await import` of secret-store // inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so @@ -81,6 +89,7 @@ import { InMemorySecretStore, KeyringSecretStore, KeychainUnavailableError, + KeyringModuleShapeError, SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, parseAccount, @@ -330,6 +339,59 @@ describe("KeyringSecretStore (mocked native bindings)", () => { expect(err.message).toMatch(/libsecret/); }); + // The two hint branches are asserted by direct construction rather than + // through the store: the unloadable-package path can only be reached via + // a throwing `vi.doMock` factory, and vitest substitutes its own "error + // when mocking a module" message for whatever that factory throws — so + // the real loader text never reaches the constructor from there. These + // two cases pin the wording against the message the napi-rs loader + // actually produces. + it("KeychainUnavailableError steers a missing native binding to a reinstall", () => { + const err = new KeychainUnavailableError( + new Error( + "Cannot find native binding. npm has a bug related to optional dependencies…", + ), + ); + expect(err.message).toMatch(/reinstall the Inspector/); + expect(err.message).toMatch(/npx cache/); + // The Linux keyring-daemon advice is irrelevant to this cause. + expect(err.message).not.toMatch(/libsecret/); + expect(err.message).toMatch(/Cannot find native binding/); + }); + + it("KeychainUnavailableError points a wrong-shape module at a reinstall, not at libsecret", () => { + // A packaging/version mismatch is not a missing keyring daemon, so + // the libsecret line would send the user somewhere that cannot help. + const err = new KeychainUnavailableError( + new KeyringModuleShapeError("did not expose AsyncEntry"), + ); + expect(err.message).toMatch(/does not expose the API this build expects/); + expect(err.message).not.toMatch(/libsecret/); + }); + + it("KeychainUnavailableError gives the same hint however the shape check failed", () => { + // The shape check fails two ways — members absent, or reading them + // throws. Both are the same underlying problem, so both must earn the + // packaging hint; only one of them carrying it was the original bug. + const err = new KeychainUnavailableError( + new KeyringModuleShapeError("its exports could not be read: boom", { + cause: new Error("boom"), + }), + ); + expect(err.message).toMatch(/does not expose the API this build expects/); + expect(err.message).not.toMatch(/libsecret/); + // The original failure is still legible in the message. + expect(err.message).toMatch(/boom/); + }); + + it("KeychainUnavailableError keeps the libsecret advice for other causes", () => { + const err = new KeychainUnavailableError( + new Error("failed to unlock the default collection"), + ); + expect(err.message).toMatch(/libsecret/); + expect(err.message).not.toMatch(/reinstall the Inspector/); + }); + it("KeychainUnavailableError carries the underlying error message", async () => { keyringMocks.failures.setThrows = true; try { @@ -386,3 +448,254 @@ describe("KeyringSecretStore (mocked native bindings)", () => { }); }); }); + +describe("@napi-rs/keyring unloadable on this platform (#1905)", () => { + // `@napi-rs/keyring` ships one prebuilt binary per platform triple and + // throws on import where it has none — Android / Termux is the reported + // case. A static top-level import made that a startup crash: the module + // never evaluated, so none of the tolerance handling could run. + // + // The shared stub above can't express this: it models a keyring that + // loads. Here the *import itself* rejects, which needs a fresh module + // registry — hence `vi.resetModules()` + `vi.doMock` and a re-import + // rather than a flag. The re-imported module is a distinct instance, so + // its `KeychainUnavailableError` is a distinct class identity too; the + // assertions below use the freshly imported one. + // The real-world message on Termux. Documentary here — see the cause + // assertion below for why vitest doesn't let it through verbatim. + const LOAD_ERROR = "Cannot find module '@napi-rs/keyring-android-arm64'"; + + /** Fresh secret-store module whose `@napi-rs/keyring` import rejects. */ + const importWithUnloadableKeyring = async ( + onLoadAttempt: () => void = () => {}, + ) => { + vi.resetModules(); + vi.doMock("@napi-rs/keyring", () => { + onLoadAttempt(); + throw new Error(LOAD_ERROR); + }); + return await import("@inspector/core/auth/node/secret-store.js"); + }; + + afterEach(() => { + vi.doUnmock("@napi-rs/keyring"); + vi.resetModules(); + }); + + it("the module still evaluates — importing it must not throw", async () => { + // The regression itself: with a static top-level import this rejects, + // and the Inspector exits before reaching any fallback. + await expect(importWithUnloadableKeyring()).resolves.toHaveProperty( + "KeyringSecretStore", + ); + }); + + it("get returns null", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + }); + + it("set throws KeychainUnavailableError carrying the load failure as its cause", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + // Asserted by shape, not by the literal text: vitest substitutes its + // own "error when mocking a module" message for a throwing `doMock` + // factory, so `LOAD_ERROR` never reaches the store here. What matters + // — and what is testable — is that the load failure is appended as + // the cause rather than swallowed. In production the real + // "Cannot find native binding" text lands in that slot. + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toThrow(/Underlying error: .+/); + }); + + it("set does not double-wrap the typed error", async () => { + // The load failure is already a `KeychainUnavailableError` by the time + // the catch sees it; re-wrapping would bury the cause a level deeper. + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + try { + await store.set("alpha", "oauth-client-secret", "v"); + throw new Error("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(mod.KeychainUnavailableError); + // One "OS keychain is not available" prefix, not two nested. + expect( + (err as Error).message.match(/OS keychain is not available/g), + ).toHaveLength(1); + } + }); + + it("delete and deleteAllForServer silently no-op", async () => { + const mod = await importWithUnloadableKeyring(); + const store = new mod.KeyringSecretStore(); + await expect( + store.delete("alpha", "oauth-client-secret"), + ).resolves.toBeUndefined(); + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("attempts the load once and caches the failure", async () => { + // Without the cache every secret operation re-attempts (and re-throws) + // the resolution — `expectedSecretFields` means that is once per + // server per `GET /api/servers`. + const onLoadAttempt = vi.fn(); + const mod = await importWithUnloadableKeyring(onLoadAttempt); + const store = new mod.KeyringSecretStore(); + + await store.get("alpha", "oauth-client-secret"); + await store.get("beta", "oauth-client-secret"); + await store.delete("alpha", "oauth-client-secret"); + + expect(onLoadAttempt).toHaveBeenCalledTimes(1); + }); +}); + +describe("@napi-rs/keyring loads but exposes the wrong shape", () => { + // The package is CJS, so `AsyncEntry` / `findCredentialsAsync` reach us + // through named-export interop. That holds today — verified against the + // real package — but if it ever stopped (a default-only export upstream, + // a bundler changing interop, a platform where named-export detection + // fails), the members would be `undefined` and `new undefined(...)` would + // throw a TypeError *inside* the try that implements degradation. + // + // What the shape check fixes is the *diagnosis*, not the data loss: `get` + // returns null with or without it (read-tolerance is its contract), so + // the empty secret list looks the same either way. The difference is that + // `set` names the shape problem at the load boundary instead of reporting + // "keyring.mod.AsyncEntry is not a constructor". Only the cause-message + // test below actually fails without the guard — the others pin the + // surrounding contract and would pass either way. + const importWithKeyringShape = async (shape: Record<string, unknown>) => { + vi.resetModules(); + vi.doMock("@napi-rs/keyring", () => shape); + return await import("@inspector/core/auth/node/secret-store.js"); + }; + + afterEach(() => { + vi.doUnmock("@napi-rs/keyring"); + vi.resetModules(); + }); + + // The members are declared but not callable, rather than absent. That is + // the shape a default-only export would leave behind, and it keeps these + // cases on the `typeof !== "function"` branch deterministically: vitest's + // module mock *throws* on reading a key the factory never returned, which + // is a different path (covered by its own case at the end). + const ENTRY_NOT_A_FUNCTION = { + AsyncEntry: undefined, + findCredentialsAsync: async () => [], + }; + + it("hard-fails set for a namespace without a callable AsyncEntry (get still returns null by contract)", async () => { + const mod = await importWithKeyringShape(ENTRY_NOT_A_FUNCTION); + const store = new mod.KeyringSecretStore(); + + // `get` returning null is the read-tolerance contract, not something + // the shape check changes — it reads the same as "no secret stored", + // which is exactly why `set` has to be the operation that hard-fails. + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + }); + + it("names the shape problem as the cause, not a bare unavailability", async () => { + const mod = await importWithKeyringShape(ENTRY_NOT_A_FUNCTION); + const store = new mod.KeyringSecretStore(); + try { + await store.set("alpha", "oauth-client-secret", "v"); + throw new Error("expected throw"); + } catch (err) { + expect((err as Error).message).toMatch( + /did not expose AsyncEntry \/ findCredentialsAsync/, + ); + // Not double-wrapped: the load failure is already typed by the time + // `set`'s catch sees it. + expect( + (err as Error).message.match(/OS keychain is not available/g), + ).toHaveLength(1); + } + }); + + it("treats a namespace without a callable findCredentialsAsync as unavailable too", async () => { + // `deleteAllForServer` is the only caller of it, so a shape check on + // `AsyncEntry` alone would let this one through to a TypeError. + class StubEntry { + async getPassword(): Promise<string | undefined> { + return undefined; + } + } + const mod = await importWithKeyringShape({ + AsyncEntry: StubEntry, + findCredentialsAsync: undefined, + }); + const store = new mod.KeyringSecretStore(); + + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("treats a namespace that throws on member access as unavailable", async () => { + // Reading a missing export is not always a harmless `undefined` — a + // Proxy-backed namespace can throw, and vitest's module mock does. If + // that throw escaped the shape check it would reject the *cached* + // promise, so the check has to absorb it and report unavailable. + const mod = await importWithKeyringShape({}); + const store = new mod.KeyringSecretStore(); + + expect(await store.get("alpha", "oauth-client-secret")).toBe(null); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toBeInstanceOf(mod.KeychainUnavailableError); + // …and it reaches the user as a packaging problem, not as "install + // libsecret". Returning the raw access error here would have been + // typed correctly but hinted wrongly. + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.toThrow(/does not expose the API this build expects/); + await expect( + store.set("alpha", "oauth-client-secret", "v"), + ).rejects.not.toThrow(/libsecret/); + // Absorbed, not escaped: a rejected cached promise would surface here + // as the raw access error instead of the typed one. + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("accepts a well-formed namespace", async () => { + // The guard must not reject the shape the package actually ships — + // otherwise it would turn a working keychain into a permanent 503. + const stored = new Map<string, string>(); + class StubEntry { + // Declared explicitly rather than as a constructor parameter + // property: those are disallowed under `erasableSyntaxOnly`. + private readonly account: string; + constructor(_service: string, account: string) { + this.account = account; + } + async getPassword(): Promise<string | undefined> { + return stored.get(this.account); + } + async setPassword(value: string): Promise<void> { + stored.set(this.account, value); + } + async deleteCredential(): Promise<boolean> { + return stored.delete(this.account); + } + } + const mod = await importWithKeyringShape({ + AsyncEntry: StubEntry, + findCredentialsAsync: async () => [], + }); + const store = new mod.KeyringSecretStore(); + + await store.set("alpha", "oauth-client-secret", "shh"); + expect(await store.get("alpha", "oauth-client-secret")).toBe("shh"); + }); +}); diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index a1adb232..2ccefb43 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -13,10 +13,124 @@ * browser side never imports this; it gets values rehydrated into the * `/api/servers` response by the Hono handler. */ -import { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring"; const SERVICE_NAME = "mcp-inspector"; +/** + * `@napi-rs/keyring` ships one prebuilt binary per platform triple, and + * loading the package *throws* on a platform it has no binary for — + * Android / Termux is the reported case (#1905), where the import fails + * with "Cannot find native binding" / "Cannot find module + * '@napi-rs/keyring-android-arm64'". + * + * A static top-level import made that a startup crash: the module never + * evaluated, so the Inspector exited before any of the + * keychain-unavailable handling below could run. Loading it lazily, and + * caching the *outcome* rather than only the module, folds an + * unsupported platform into the same degradation contract as an + * unreachable keychain (see `KeyringSecretStore`) — the store is simply + * unavailable, and callers see it through the documented behavior + * instead of a crash. + * + * The failure is cached alongside the success so a box without a binary + * doesn't re-attempt (and re-throw) the resolution on every secret + * operation, and so `set` can name the underlying cause in its + * `KeychainUnavailableError`. + * + * The cache is process-lifetime by design — there is no reset seam, since + * a platform does not grow a native binary mid-run. Tests reach the + * unloadable path through `vi.resetModules()` + `vi.doMock`, which gives + * them a fresh module (and so a fresh cache) instead. + * + * A resolved import is also **shape-checked** before being accepted. The + * package is CJS, so the named exports we rely on come from interop, and + * a resolution that stopped yielding them (a default-only export in a + * future version, a bundler changing interop, a platform where the + * named-export detection fails) would land as `undefined`, making + * `new mod.AsyncEntry(...)` throw `TypeError: not a constructor` from + * inside the very `try` that implements graceful degradation. + * + * Be precise about what this buys, because it is narrower than it looks: + * it does **not** stop a bad shape from emptying the secret list. `get` + * returns `null` either way — that is its read-tolerance contract, and + * the `TypeError` lands in the same `catch` that a dead keychain does. + * What the check changes is *diagnosis*. Without it the only signal is + * `set` reporting "keyring.mod.AsyncEntry is not a constructor", an + * internal-looking message that reads like an Inspector bug; with it, + * `set` names the actual problem once, at the load boundary, in the same + * actionable 503 as every other flavor of unavailability. Detecting the + * silent-empty-list case itself would take a real round-trip against the + * unmocked package, which nothing in the suite does today. + */ +type KeyringModule = typeof import("@napi-rs/keyring"); +type KeyringLoad = + | { ok: true; mod: KeyringModule } + | { ok: false; err: unknown }; + +let keyringLoad: Promise<KeyringLoad> | undefined; + +/** + * Accept a resolved import only if it carries the two members we call. + * + * The member *access* is inside the `try` because reading a missing + * export is not always the harmless `undefined` a plain ESM namespace + * gives: a Proxy-based namespace can throw on an unknown key (vitest's + * module mocks do exactly that). Either way the answer is the same — + * unavailable — and returning it rather than throwing is what keeps the + * cached promise from ever rejecting. + */ +/** + * Marks the shape-check failure so `KeychainUnavailableError` can give + * advice that fits it. A distinct type rather than a string match on the + * message: this is our own error, so there is no reason to re-parse text + * we just wrote (the native-binding branch matches on a string only + * because that message comes from someone else's loader). + */ +export class KeyringModuleShapeError extends Error { + constructor(detail: string, options?: { cause?: unknown }) { + super(`@napi-rs/keyring loaded but ${detail}`, options); + this.name = "KeyringModuleShapeError"; + } +} + +const checkKeyringShape = (mod: KeyringModule): KeyringLoad => { + try { + if ( + typeof mod.AsyncEntry === "function" && + typeof mod.findCredentialsAsync === "function" + ) { + return { ok: true, mod }; + } + return { + ok: false, + err: new KeyringModuleShapeError( + "did not expose AsyncEntry / findCredentialsAsync", + ), + }; + } catch (err) { + // Both ways of failing the shape check are the same problem — the + // module is not the API we expect — so both carry the type that + // earns the packaging hint. Returning the raw error here instead + // would drop it back to the libsecret advice, which is what this + // whole branch exists to avoid. + return { + ok: false, + err: new KeyringModuleShapeError( + `its exports could not be read: ${err instanceof Error ? err.message : String(err)}`, + { cause: err }, + ), + }; + } +}; + +const loadKeyring = (): Promise<KeyringLoad> => { + keyringLoad ??= import("@napi-rs/keyring").then( + checkKeyringShape, + (err: unknown): KeyringLoad => ({ ok: false, err }), + ); + return keyringLoad; +}; + export { SECRET_FIELD_OAUTH_CLIENT_SECRET, SECRET_FIELD_IDP_CLIENT_SECRET, @@ -39,22 +153,50 @@ const buildAccount = (serverId: string, field: string): string => `${serverId}:${field}`; /** - * Thrown when the OS keychain is unavailable — typically Linux without - * libsecret / gnome-keyring installed. Surfaced as a 503 by the API - * handlers so the UI can show an actionable error rather than a generic - * 500. macOS and Windows always have a working keychain, so this only - * realistically fires on minimal Linux installs. + * Thrown when the OS keychain is unavailable. Surfaced as a 503 by the + * API handlers so the UI can show an actionable error rather than a + * generic 500 — and "actionable" is the point: the causes need + * *different* fixes, so the message carries a hint chosen per cause + * (see `hintFor`). Three realistic ones: + * + * - **The keychain itself is missing** — Linux without libsecret / + * gnome-keyring. Install it. + * - **`@napi-rs/keyring` won't load** — no platform binary for this + * triple (Android/Termux, #1905) or npm's optional-deps bug dropping + * it on a supported one (npm/cli#4828). Reinstall / clear the npx + * cache; installing a keyring daemon would not help. + * - **It loads but exposes the wrong API** — a version or packaging + * mismatch (`KeyringModuleShapeError`). Also not a daemon problem. */ export class KeychainUnavailableError extends Error { constructor(cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); super( - `OS keychain is not available. On Linux, install libsecret / gnome-keyring. ` + - `Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`, + `OS keychain is not available. ${hintFor(cause, message)} Underlying error: ${message}`, ); this.name = "KeychainUnavailableError"; } } +/** + * The remediation that fits the cause. Wrong advice is worse than none — + * telling someone on Windows to install libsecret sends them down a path + * that cannot work — so every cause that has its own fix gets its own + * branch, and the libsecret line is the fallback rather than the default. + */ +const hintFor = (cause: unknown, message: string): string => { + // Our own error, so match on the type rather than re-parsing text we wrote. + if (cause instanceof KeyringModuleShapeError) { + return `The @napi-rs/keyring package loaded but does not expose the API this build expects — most likely a version or packaging mismatch; reinstall the Inspector, and report this if it persists.`; + } + // This phrasing comes from the napi-rs loader, not from us: it is what + // the package throws when the platform binary is missing. + if (message.includes("Cannot find native binding")) { + return `The @napi-rs/keyring platform package for this OS is missing or unavailable — reinstall the Inspector (for npx, clear the npx cache under your npm cache directory first).`; + } + return `On Linux, install libsecret / gnome-keyring.`; +}; + /** * Storage interface for the per-server secrets we lift off * `~/.mcp-inspector/mcp.json`. Implemented by `KeyringSecretStore` (the @@ -79,28 +221,48 @@ export interface SecretStore { * can use `=== null` rather than truthiness (an empty-string secret is * a real value and must round-trip). * - * **Availability behavior.** When the keychain is unavailable (the - * typical case is Linux without libsecret / gnome-keyring, or a - * container with no D-Bus session), `set` is the only operation that - * throws `KeychainUnavailableError` — that's the moment where data - * would actually be lost. `get` returns `null` (as if no entry - * existed) and the destructive operations silently no-op (there's - * nothing to delete anyway). This keeps non-secret flows working on a - * stock CI runner / minimal Linux box; the user only hits a hard error - * when they actually try to save a secret. + * **Availability behavior.** When the keychain is unavailable, `set` is + * the only operation that throws `KeychainUnavailableError` — that's + * the moment where data would actually be lost. `get` returns `null` + * (as if no entry existed) and the destructive operations silently + * no-op (there's nothing to delete anyway). This keeps non-secret flows + * working on a stock CI runner / minimal Linux box / unsupported + * platform; the user only hits a hard error when they actually try to + * save a secret. * - * The `AsyncEntry` construction is deliberately **inside** each - * method's `try`: `AsyncEntry::new` performs the platform-store setup - * (on Linux, the Secret Service connect with a keyutils fallback) and - * throws when no backend is reachable. Constructing it outside the - * `try` let that raw error escape, 500ing every `GET /api/servers` - * before any secret was involved — the contract above only holds if - * construction failures are funneled through the same handlers (#1848). + * "Unavailable" covers four distinct failures, all funneled into that + * one contract — the contract is only as good as its narrowest funnel, + * and each of these escaped it at some point: + * + * 1. **The package won't load at all** — no prebuilt binary for this + * platform (Android / Termux). A static top-level import made this a + * startup crash before any handling ran (#1905); `loadKeyring()` + * above defers and caches it instead. + * 2. **The package loads but exposes the wrong shape** — the named + * exports arrive via CJS interop, so a resolution that stopped + * yielding them would hand us `undefined` and fail as a `TypeError` + * swallowed by the degradation path. `loadKeyring()` shape-checks up + * front so `set` can name that cause instead of surfacing an + * "is not a constructor" message (see the note there — the check + * improves the diagnosis, it does not change what `get` returns). + * 3. **`AsyncEntry::new` throws** — it performs the platform-store setup + * (on Linux, the Secret Service connect with a keyutils fallback) and + * throws when no backend is reachable. Construction is therefore + * deliberately **inside** each method's `try`; outside it, the raw + * error escaped and 500'd every `GET /api/servers` before any secret + * was involved (#1848). + * 4. **The operation itself throws** — the original case, and the only + * one the first version of this contract actually handled. */ export class KeyringSecretStore implements SecretStore { async get(serverId: string, field: string): Promise<string | null> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + const keyring = await loadKeyring(); + if (!keyring.ok) return null; + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); const v = await entry.getPassword(); return v ?? null; } catch { @@ -114,9 +276,20 @@ export class KeyringSecretStore implements SecretStore { async set(serverId: string, field: string, value: string): Promise<void> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + const keyring = await loadKeyring(); + // An unloadable package is as fatal to a write as an unreachable + // keychain, and for the same reason — the value would vanish. + if (!keyring.ok) throw new KeychainUnavailableError(keyring.err); + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); await entry.setPassword(value); } catch (err) { + // Already the typed error when the module failed to load — don't + // double-wrap it (that would bury the underlying cause one level + // deeper in the message). + if (err instanceof KeychainUnavailableError) throw err; // The only operation that hard-fails — if we can't persist the // secret, the user needs to know now rather than discover later // that their value disappeared. Routes translate this to a 503. @@ -126,7 +299,12 @@ export class KeyringSecretStore implements SecretStore { async delete(serverId: string, field: string): Promise<void> { try { - const entry = new AsyncEntry(SERVICE_NAME, buildAccount(serverId, field)); + const keyring = await loadKeyring(); + if (!keyring.ok) return; + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); await entry.deleteCredential(); } catch { // Every reason for a throw collapses to the same desired outcome @@ -142,7 +320,9 @@ export class KeyringSecretStore implements SecretStore { async deleteAllForServer(serverId: string): Promise<void> { let creds: Array<{ account: string; password: string }>; try { - creds = await findCredentialsAsync(SERVICE_NAME); + const keyring = await loadKeyring(); + if (!keyring.ok) return; + creds = await keyring.mod.findCredentialsAsync(SERVICE_NAME); } catch { // Same reasoning as `delete`: nothing was written, nothing to sweep. return; From 1881e5c73f551080f3509fbc22e933efe4bf31ce Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 17:02:05 -0400 Subject: [PATCH 038/941] fix: settle the deliberately in-flight tool calls in the progress tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two progress tests fire `client.callTool()` without holding the promise so they can assert on the notifications the call streams while it is still in flight. `disconnect()` then closes the SDK client, which rejects every pending request with "Connection closed" — with nothing holding the promise that lands as an unhandled rejection, which vitest counts as a run error and fails `npm run ci` at `coverage` even though all tests pass. Add a `settleInFlight()` helper that attaches the handler at call time (not after the assertions, which would leave a window the rejection can escape through) and returns a promise the test awaits after `disconnect()` so teardown stays ordered. Neither outcome is asserted: the call may legitimately complete before the teardown or reject with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219..3586e51f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -95,6 +95,29 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { throw new Error(`Tool ${name} not found`); } +/** + * Hold a deliberately un-awaited in-flight call so its rejection is handled. + * + * A few tests start a tool call, assert on the notifications it streams, and + * then tear the connection down while the call is still in flight. That is a + * legitimate thing to exercise, but `disconnect()` closes the SDK client, which + * rejects every pending request with "Connection closed" — and a floating + * promise makes that an *unhandled* rejection, which vitest counts as a run + * error and fails `npm run ci` even though every test passes (#1947). + * + * Attach the handler at call time (not after the assertions) so there is no + * window in which the rejection can escape, and `await` the returned promise + * after `disconnect()` so teardown stays ordered. The call may legitimately + * either settle before the teardown or reject with it, so neither outcome is + * asserted here. + */ +function settleInFlight(call: Promise<unknown>): Promise<void> { + return call.then( + () => undefined, + () => undefined, + ); +} + /** Get all resources from the client via listResources() (paginates if needed). */ async function getAllResources( client: InspectorClient, @@ -2221,16 +2244,18 @@ describe("InspectorClient", () => { const progressToken = 12345; const sendProgressTool = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool, - { - units: 3, - delayMs: 50, - total: 3, - message: "Test progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool, + { + units: 3, + delayMs: 50, + total: 3, + message: "Test progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 3, { @@ -2262,6 +2287,7 @@ describe("InspectorClient", () => { }); await client!.disconnect(); + await inFlight; await server.stop(); }); @@ -2349,15 +2375,17 @@ describe("InspectorClient", () => { const progressToken = 67890; const sendProgressTool2 = await getTool(client, "send_progress"); - client.callTool( - sendProgressTool2, - { - units: 2, - delayMs: 50, - message: "Indeterminate progress", - }, - undefined, // generalMetadata - { progressToken: progressToken.toString() }, // toolSpecificMetadata + const inFlight = settleInFlight( + client.callTool( + sendProgressTool2, + { + units: 2, + delayMs: 50, + message: "Indeterminate progress", + }, + undefined, // generalMetadata + { progressToken: progressToken.toString() }, // toolSpecificMetadata + ), ); const progressEvents = await waitForProgressCount(client, 2, { @@ -2380,6 +2408,7 @@ describe("InspectorClient", () => { expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); await client!.disconnect(); + await inFlight; await server.stop(); }); From 0b9aee11a313e4d9afe056575766730913c9f5a9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:01:27 -0400 Subject: [PATCH 039/941] fix: only absorb the teardown's own rejection in settleInFlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the blanket rejection handler also converted a genuine `callTool` failure into a pass. That matters here because these tests assert on the progress notifications, which arrive before the result — so a call that emitted all three progress events and then failed would have satisfied every assertion and been swallowed. Narrow the handler to absorb only `SdkError` / `CONNECTION_CLOSED` and re-throw anything else. The handler is still attached at call time, so an unexpected rejection stays *handled* and surfaces as an ordinary test failure at the `await inFlight` after `disconnect()` rather than as another unhandled rejection. Fulfillment is still accepted: whether the call beats the teardown is a race, so asserting either outcome would reintroduce the original bug as a flake. Verified the narrowed predicate is actually exercised rather than dead code — both call sites reject with SdkError/CONNECTION_CLOSED on every run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 3586e51f..9f5379a7 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -107,14 +107,27 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * * Attach the handler at call time (not after the assertions) so there is no * window in which the rejection can escape, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. The call may legitimately - * either settle before the teardown or reject with it, so neither outcome is - * asserted here. + * after `disconnect()` so teardown stays ordered. + * + * Only the teardown's own `CONNECTION_CLOSED` rejection is absorbed. Plain + * fulfillment is fine too — whether the call beats the teardown is a race, so + * asserting either outcome would turn this straight back into a flake. Any + * *other* rejection is re-thrown, so a call that fails for a real reason still + * fails the test instead of passing on the strength of the progress + * notifications it managed to emit first. */ function settleInFlight(call: Promise<unknown>): Promise<void> { return call.then( () => undefined, - () => undefined, + (error: unknown) => { + if ( + error instanceof SdkError && + error.code === SdkErrorCode.ConnectionClosed + ) { + return; + } + throw error; + }, ); } From c60563b7d647303ce15b34690836f3ffd8a7f127 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:09:52 -0400 Subject: [PATCH 040/941] fix: observe the derived promise so an unexpected rejection can't go unhandled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 2). Narrowing the handler in the previous commit fixed the silent-pass but reintroduced #1947's own failure class on the unexpected path: `then` returns a *derived* promise, and the re-throw rejects that one rather than `call`. The caller doesn't await it until after `disconnect()`, so a rejection arriving while the test is still waiting on progress notifications sits unobserved for seconds and Node reports it as an unhandled rejection. Attach an observer to the derived promise the moment it exists. Nothing is swallowed — it stays rejected, so the caller's `await` still fails the test. Verified both directions with a probe rejecting with a non-ConnectionClosed error and awaiting after a 1.5s gap: without the observer the run reports "Unhandled Rejection" + "Errors 1 error"; with it, the test fails and the run stays clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../src/test/integration/mcp/inspectorClient.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9f5379a7..9a3a690c 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -117,7 +117,7 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * notifications it managed to emit first. */ function settleInFlight(call: Promise<unknown>): Promise<void> { - return call.then( + const settled = call.then( () => undefined, (error: unknown) => { if ( @@ -129,6 +129,15 @@ function settleInFlight(call: Promise<unknown>): Promise<void> { throw error; }, ); + // `then` returns a *derived* promise, and the re-throw above rejects that one + // — not `call`. The caller does not await it until after `disconnect()`, so an + // unexpected rejection arriving while the test is still waiting on progress + // notifications would sit unobserved for seconds and be reported as an + // unhandled rejection: precisely the failure this helper exists to prevent. + // Observe it the moment it exists. This does not swallow anything — `settled` + // stays rejected, so the caller's `await` still fails the test. + settled.catch(() => undefined); + return settled; } /** Get all resources from the client via listResources() (paginates if needed). */ From 347173fab7363fa25f0a50b1b4b246c72028757f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 18:19:33 -0400 Subject: [PATCH 041/941] fix: absorb only the teardown's own close, not any ConnectionClosed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (round 3). The predicate absorbed *any* CONNECTION_CLOSED, while the doc comment claimed it absorbed only the teardown's — so a transport dropping on its own after the progress notifications but before `disconnect()` would have been swallowed. These tests assert only on the notifications, so that regression would have passed green. Gate the absorption on a teardown flag owned by the helper, which now returns an `InFlightCall` exposing `disconnectAndSettle()`. Keeping the flag inside means it can't be raised too early (which would silently reopen the hole) and the settle can't be forgotten; call sites also collapse from two statements to one. Verified: a pre-teardown ConnectionClosed now fails the test and stays observed (no run-level error), while a teardown-time one is still absorbed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../integration/mcp/inspectorClient.test.ts | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 9a3a690c..cc208835 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -106,21 +106,33 @@ async function getTool(client: InspectorClient, name: string): Promise<Tool> { * error and fails `npm run ci` even though every test passes (#1947). * * Attach the handler at call time (not after the assertions) so there is no - * window in which the rejection can escape, and `await` the returned promise - * after `disconnect()` so teardown stays ordered. + * window in which the rejection can escape, then finish through + * `disconnectAndSettle()`, which tears down and awaits the call in one step. * - * Only the teardown's own `CONNECTION_CLOSED` rejection is absorbed. Plain + * Only a `CONNECTION_CLOSED` raised *by that teardown* is absorbed. Plain * fulfillment is fine too — whether the call beats the teardown is a race, so - * asserting either outcome would turn this straight back into a flake. Any - * *other* rejection is re-thrown, so a call that fails for a real reason still - * fails the test instead of passing on the strength of the progress - * notifications it managed to emit first. + * asserting either outcome would turn this straight back into a flake. Every + * other rejection is re-thrown, including a `CONNECTION_CLOSED` that arrives + * before teardown begins: a transport that drops on its own after emitting the + * progress notifications is a real regression, and absorbing it would let these + * tests pass on the strength of the notifications alone. + * + * The teardown flag is owned by the helper and set inside `disconnectAndSettle` + * rather than by the caller, so the flag cannot be raised too early (which would + * reopen the hole) and the await cannot be forgotten. */ -function settleInFlight(call: Promise<unknown>): Promise<void> { +interface InFlightCall { + /** Disconnect, then await the call — absorbing only this teardown's close. */ + disconnectAndSettle(client: InspectorClient): Promise<void>; +} + +function settleInFlight(call: Promise<unknown>): InFlightCall { + let tearingDown = false; const settled = call.then( () => undefined, (error: unknown) => { if ( + tearingDown && error instanceof SdkError && error.code === SdkErrorCode.ConnectionClosed ) { @@ -135,9 +147,15 @@ function settleInFlight(call: Promise<unknown>): Promise<void> { // notifications would sit unobserved for seconds and be reported as an // unhandled rejection: precisely the failure this helper exists to prevent. // Observe it the moment it exists. This does not swallow anything — `settled` - // stays rejected, so the caller's `await` still fails the test. + // stays rejected, so the caller's `await` below still fails the test. settled.catch(() => undefined); - return settled; + return { + async disconnectAndSettle(client: InspectorClient): Promise<void> { + tearingDown = true; + await client.disconnect(); + await settled; + }, + }; } /** Get all resources from the client via listResources() (paginates if needed). */ @@ -2308,8 +2326,7 @@ describe("InspectorClient", () => { progressToken: progressToken.toString(), }); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); @@ -2429,8 +2446,7 @@ describe("InspectorClient", () => { }); expect((progressEvents[1] as { total?: number }).total).toBeUndefined(); - await client!.disconnect(); - await inFlight; + await inFlight.disconnectAndSettle(client!); await server.stop(); }); From 2e8944373937a32e450eca2eab588e6e6500abfb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 19:23:43 -0400 Subject: [PATCH 042/941] =?UTF-8?q?docs:=20add=20the=20v1=20=E2=86=92=20v2?= =?UTF-8?q?=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1822 Adds docs/v1-to-v2-migration.md — the map for users arriving from v1: - CLI flag mapping (every v1 flag survives; lists what v2 adds) - `--config` vs `--catalog` semantics with before/after examples - Node engine bump (>=22.7.5 → >=22.19.0) - what no longer ships (the three sub-packages; v2 is one tarball) - the three behavior changes most likely to bite: exit code 5 on a failing tool call, target-must-come-first under --cli, and the reversed `--` separator - env-var mapping (MCP_PROXY_AUTH_TOKEN → MCP_INSPECTOR_API_TOKEN, MCP_PROXY_FULL_ADDRESS removed, SERVER_PORT repurposed), the v1 UI config settings, web query params, and Docker Linked from the root README (banner + docs list), the CLI README, and docs/mcp-server-configuration.md; AGENTS.md's docs/ tree entry updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- AGENTS.md | 216 ++++++++++---------- README.md | 51 ++--- clients/cli/README.md | 2 + docs/mcp-server-configuration.md | 1 + docs/v1-to-v2-migration.md | 325 +++++++++++++++++++++++++++++++ 5 files changed, 468 insertions(+), 127 deletions(-) create mode 100644 docs/v1-to-v2-migration.md diff --git a/AGENTS.md b/AGENTS.md index a4185baa..c1039e87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,9 @@ v2/main/ │ # in clients/web/vite.config.ts points at build/index.js │ # (not src/) so `getTestMcpServerPath()` returns a `.js` path. │ # tsconfig.test.json keeps paths pointing at src for typecheck. -├── docs/ # Task-oriented guides (mcp-server-configuration.md, -│ # mcp-app-review.md, launcher-config-consolidation-plan.md, +├── docs/ # Task-oriented guides (v1-to-v2-migration.md, +│ # mcp-server-configuration.md, mcp-app-review.md, +│ # launcher-config-consolidation-plan.md, │ # images/). Linked from the root README. ├── scripts/ # Root build/verify tooling: install-clients.mjs (the │ # postinstall cascade), the smoke-*.mjs runners, @@ -120,20 +121,22 @@ If you've already built a change locally, share the **prompt** you used and scre **This applies to org members with write access too, not just outside contributors.** Having permission to push a branch is not authorization to open a PR. Pull requests against this repo are opened by the **repo maintainers** only. Anyone else — including organization members whose write access makes it technically possible — opens a **detailed issue** instead, and a maintainer takes it from there. A detailed issue means: the problem, how to reproduce it, the behavior you expected, and — if you've already prototyped a fix — the prompt you used and any screenshots, rather than a diff. -**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line *dropdown* and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). +**Issues are filed through the forms in [`.github/ISSUE_TEMPLATE/`](./.github/ISSUE_TEMPLATE) — blank issues are disabled.** GitHub serves the chooser from the **default branch** only, so a form edited here on `v2/main` has no effect on the live chooser until the next milestone merge into `main` — and it cannot be previewed before then, which is why the schema notes below matter. There are two forms, **Bug report** (`1-bug_report.yml`, auto-labels `bug`) and **Feature request** (`2-feature_request.yml`, auto-labels `enhancement` **and `v2`**); `config.yml` holds the chooser's contact links. A form's `labels:` is **static** — GitHub cannot map a reporter's answer to a label — which splits the two cases: the **bug** form could target either line, so it carries a required version-line _dropdown_ and a maintainer applies the matching label at triage per [Label by version](#issue-driven-work-style); the **feature** form is v2 by construction (v1 takes security fixes only and cannot receive a feature), so it needs no dropdown and declares `v2` statically. If v1 ever reopens to features, that static label is what has to change. **There is deliberately no security template**: a vulnerability report must not open a public issue, so the chooser routes it to the private advisory form as a contact link instead (see [`SECURITY.md`](./SECURITY.md)). When adding or changing a form, validate it against GitHub's issue-forms schema (`markdown` blocks take no `id` and no `validations`; `checkboxes` mark `required` per option, not under `validations`). **Every PR must reference an issue. No exceptions, regardless of who opens it.** The PR body's first line is `Closes #<ISSUE_NUMBER>` (see the [Issue-driven Work Style](#issue-driven-work-style) rules below). A PR with no linked issue has no board card, so the work is invisible to the project board and untracked — if you're about to open one and there's no issue yet, create the issue first. This holds for a maintainer's own one-line fix as much as for a feature. ## Project Status and Direction -* The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. +- The v1/main branch currently contains the legacy version of the Inspector, which we are creating security fixes for in deprecated maintenance mode. It is **published straight from the branch** to the `v1-latest` npm dist-tag — v1 releases never pass through `main`, and v1 PRs therefore target `v1/main` directly. -* The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. +- The v2/main branch currently contains the the new version of the Inspector, which is actively being developed and maintained. All new features, bug fixes, and refactors should be implemented in this branch. It acts as the **develop branch**: work accumulates here continuously and is merged into `main` at milestone releases. + +- The main branch is the default branch for the repo, and it currently points to the latest v2 release. It is not a development branch, and no new features or bug fixes should be implemented here. It is only used for releases of the v2 Inspector via merge from v2/main, which is what publishes the `latest` npm dist-tag. ## Maintenance Rules ### Keep documentation files up to date + - When adding, removing, renaming, or changing the purpose of any file or folder, update the corresponding entry in the main README.md and/or the related clients/*/README.md - When the structure of the project, the tech stack, or the developer setup changes, update appropriate README.md files with the details. - When adding new commands, dependencies, or architectural patterns, update the relevant sections of appropriate README.md files as well. @@ -148,14 +151,14 @@ If you've already built a change locally, share the **prompt** you used and scre All work should be driven by items on the project board. -> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status *and* a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. +> **A v2 issue is not "created" until it is labeled `v2`, given a milestone, AND on board #28 with a Status _and_ a Priority set.** Labeling alone is not enough — a label is a repo tag, the milestone is a release bucket, and the board is a separate org project. Applying `--label v2` does **not** add the item to the board, and adding it to the board does **not** set a Status or a Priority. All five are distinct steps; do all five (see the recipes below). **Only issues go on the board — never PRs.** A PR still gets the `v2` label, but it is tracked through its linked issue's card (via `Closes #N`), not its own board item. - Before starting work, check the board for the relevant item. - **Every board item is a real GitHub issue.** Do not create draft items (board cards with no issue number). If you find work that needs tracking, create an actual issue and add that to the board. Before creating a new issue, check the board for a matching item to avoid duplicates — **never create a duplicate**. - **Label by version — every issue and every PR, no exceptions.** Each one carries **exactly one** of `v1` or `v2` at creation. There is no unlabeled state and no "decide later": an issue with neither label belongs to no version line, appears in no version-filtered query, and is effectively invisible. - `v1` — work targeting `v1/main` (the deprecated line: security fixes only) - `v2` — work targeting `v2/main` (active development; the default for anything new) - Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix *for released v1 behavior* and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). + Set the label at **create time** — `gh issue create --label v2 ...`, `gh pr create --label v2 ...` — never by backfilling later, since unlabeled items are exactly the ones missed when filtering by version. **If the target version isn't obvious, it's `v2`**: v2 is where all new work goes, and `v1` is reserved for the narrow case of patching the deprecated line. Only ask when the issue is specifically a fix _for released v1 behavior_ and it's unclear whether v2 still has the bug. Note the label is a repo tag and is **not** the board — see the callout above; a `v2` issue also needs a board card with a Status **and** a Priority (a `v1` one needs a Status; board #11 has no Priority field). - **Prioritize every new issue.** Every new issue must have a Priority (Urgent, High, Medium, or Low) set at creation time. Priority is a **board field**, not a label, so it lives on the card and an unboarded issue has nowhere to store it. Derive it with the rubric in [Setting issue priority](#setting-issue-priority) rather than asserting it — an unscored "this feels urgent" is exactly what the rubric exists to replace. - **Add the issue to the board and set Status and Priority.** After creating an issue, add it to the board for its version — **`v2` → board #28**, **`v1` → board #11** — and set the fields. (PRs are never added to either board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim, and take them from the section for the right board; the two projects' ids are not interchangeable. - **New and untriaged → `Incoming`.** This is the **default status for a new item on either board.** An issue nobody has evaluated yet belongs in **Incoming**, not Todo. Todo means a maintainer approved it and it is ready to be picked up; using Todo as the inbox erases that distinction and quietly promotes unreviewed work into the queue. Anything filed by an outside reporter starts in Incoming. Work you are starting immediately goes straight to In Progress. @@ -168,7 +171,8 @@ All work should be driven by items on the project board. 'map(select(.state=="open")) | sort_by(.due_on) | .[] | "\(.title)\tdue \(.due_on[0:10])\topen=\(.open_issues)"' ``` - Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by *when the work ships*, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + Milestones are **release** buckets (`v2.1.0`, `v2.2.0`, …), so pick by _when the work ships_, not by size. If a new issue plainly can't make the current milestone, say so and put it in the next one rather than leaving it blank. Sub-issues normally inherit their parent's milestone — if a sub-task must ship with its parent, they belong in the same one. + - When work begins, create a feature branch and set the item's Status to **In Progress**. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: @@ -185,36 +189,36 @@ Every issue gets a **Priority on its board card**, set when you add the issue to > ⚠️ **There are two different "Priority" fields on an issue page, and they are unrelated. Ours is the one under _Projects → Inspector V2_.** > -> | Where it appears | What it is | Ours? | -> | --- | --- | --- | -> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | -> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | +> | Where it appears | What it is | Ours? | +> | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | +> | **Projects → Inspector V2 → Priority** | The **project board** field on board #28 (`PVTSSF_lADOCt2Azc4BJVxtzg5iJE4`). Urgent/High/Medium/Low, each option carrying its rubric band in the description. | ✅ **Yes — this is the one this rubric sets.** | +> | **Fields → Priority** (above _Projects_) | A GitHub **issue field**, `IFSS_kgDOAdAWeg`. Defined at the **`modelcontextprotocol` org** and shared by every repo in it (typescript-sdk, servers, registry, …), alongside `Effort`, `Start date`, and `Target date`. Created 2026-05-06, `ORG_ONLY`. | ❌ No. Not ours, not repo-scoped. | > > They look identical — same name, same four option names — but **nothing syncs them.** Setting one does not set the other, and they will happily disagree (this was first noticed on #1891 showing `Urgent` in Fields and `High` on the board). There is no pass-through, in either direction. > > **Never delete the org-level field.** It belongs to the whole org, so removing it would strip Priority from every other `modelcontextprotocol` repo. > -> **Don't set it either — but do read it.** A value there is a *reporter's* opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. +> **Don't set it either — but do read it.** A value there is a _reporter's_ opinion, not a maintainer's assessment, so it is **untrusted input**. It feeds the rubric as a capped +1 signal bonus and nothing more; see [Trust boundary](#trust-boundary-who-can-set-what) below. **Axis 1 — Severity / impact (1–5).** How bad is it when it happens? -| Score | Means | -| --- | --- | -| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | -| 2 | Minor friction with an easy workaround. | -| 3 | A real feature is broken or missing; the workaround is annoying or partial. | -| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | -| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | +| Score | Means | +| ----- | -------------------------------------------------------------------------------------------------- | +| 1 | Cosmetic — a typo, a misaligned control, a wording nit. | +| 2 | Minor friction with an easy workaround. | +| 3 | A real feature is broken or missing; the workaround is annoying or partial. | +| 4 | A core workflow is unusable, or the Inspector reports something false about the server under test. | +| 5 | Data loss, a security vulnerability, or a release that is broken on arrival for everyone. | **Axis 2 — Urgency / staleness (1–5).** How time-sensitive or neglected is it? -| Score | Means | -| --- | --- | -| 1 | No time pressure; nothing waits on it. | -| 2 | Wanted eventually. | -| 3 | Wanted this milestone, or has sat >90 days with no activity. | -| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | -| 5 | Blocking a release, or actively hurting users on a published version right now. | +| Score | Means | +| ----- | ---------------------------------------------------------------------------------------------- | +| 1 | No time pressure; nothing waits on it. | +| 2 | Wanted eventually. | +| 3 | Wanted this milestone, or has sat >90 days with no activity. | +| 4 | Blocking other work, or tied to a dated external dependency (an SDK release, a spec deadline). | +| 5 | Blocking a release, or actively hurting users on a published version right now. | **Signal indicators (bonuses, +1 each — not an axis of their own).** These are corroborating evidence that the two axes may have undercounted, so they adjust the total rather than standing alone: @@ -227,14 +231,14 @@ Every issue gets a **Priority on its board card**, set when you add the issue to **Bands.** Axes give 2–10 and there are six bonuses, so the total runs 2–16. -| Total | Priority | Meaning | -| --- | --- | --- | -| 12+ | **Urgent** | Drop what you're doing. | -| 9–11 | **High** | Next up after current work. | -| 6–8 | **Medium** | Scheduled normally. | -| ≤5 | **Low** | Nice to have; may sit. | +| Total | Priority | Meaning | +| ----- | ---------- | --------------------------- | +| 12+ | **Urgent** | Drop what you're doing. | +| 9–11 | **High** | Next up after current work. | +| 6–8 | **Medium** | Scheduled normally. | +| ≤5 | **Low** | Nice to have; may sit. | -Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something *else* also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. +Note that severity alone doesn't reach Urgent: a 5/5 with no corroborating signals totals 10 and lands **High**. That's deliberate — Urgent is reserved for a severe problem that something _else_ also confirms is burning, and a band that everything qualifies for stops carrying information. Override the band when it's plainly wrong, but say why in the issue; a rubric nobody may overrule is a rubric people route around. Set the resulting level on the board card with the Priority recipe in the [V2 board (#28) `gh` recipes](#v2-board-28-gh-recipes) below. @@ -250,7 +254,7 @@ That asymmetry is the whole reason the reporter's value earns a flat +1 and noth - **It cannot decide an outcome.** The bonus is capped, identical for `Urgent` and `High`, and can lift an issue at most one band. Nothing a reporter can type reaches Urgent by itself: Urgent needs 12, so the issue must already sit at 11 on maintainer-assessed axes — at which point the reporter is not the reason. - **Never map the value across.** A reporter selecting `Urgent` does **not** make the board card Urgent. Doing that would hand queue position to anyone with a GitHub account, and the queue would sort by assertiveness instead of impact. -Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on *who assessed the issue* rather than on who was technically able to click. +Don't lean on GitHub's permission gate to enforce this. Whether an outside reporter can set that field today is an implementation detail that can change without notice; the rule holds either way, because it rests on _who assessed the issue_ rather than on who was technically able to click. **Assess board Priority at boarding time**, from the issue as it stands. The reporter's value is one input among several, weighted as above. @@ -259,13 +263,14 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor - **Repo**: https://github.com/modelcontextprotocol/inspector.git - **Base Branches** — three branches, three distinct roles. Target the one matching the work; never open a PR against `main`. - | Branch | Role | PRs target it? | Publishes to | - | --- | --- | --- | --- | - | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | - | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | - | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | + | Branch | Role | PRs target it? | Publishes to | + | --------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------ | + | `v2/main` | **Develop.** All active v2 work lands here. | **Yes** — every v2 PR | nothing directly; reaches npm via `main` | + | `main` | **Release.** The repo's default branch; holds the latest released v2. Not a development branch. | **No** — it only receives milestone merges from `v2/main` | `latest` | + | `v1/main` | **Maintenance.** The deprecated v1 line, security fixes only, no active development. | **Yes** — every v1 PR, directly | `v1-latest`, published straight from this branch | So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) @@ -274,7 +279,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor #### V2 board (#28) `gh` recipes -The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The *option* ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: +The board is an **org project**, so all commands use `--owner modelcontextprotocol` and the numeric project `28`. The project node id and the field ids are stable. **The _option_ ids are NOT stable — they are regenerated whenever a single-select field's option list is edited** (see the ⚠️ hazard below). If any option id here is rejected, re-fetch the current set with: ```sh # Swap "Status" for "Priority" to fetch the other field's options. @@ -282,35 +287,36 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | jq '.fields[] | select(.name=="Status") | .options' ``` -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | -| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | +| Thing | ID | +| ----------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BJVxt` | +| Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | | Priority field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` | Status option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `721a3d4c` | -| Todo | `fbdaf21e` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `721a3d4c` | +| Todo | `fbdaf21e` | | In Progress | `195df262` | -| In Review | `159c8a02` | -| Done | `259d6aab` | +| In Review | `159c8a02` | +| Done | `259d6aab` | Use **Incoming** for newly filed, untriaged work, **Todo** once a maintainer has approved it and it's ready to pick up, **In Progress** for general active work (regardless of surface), **In Review** once a PR is open, and **Done** on merge. The Incoming/Todo line is the one that matters: Todo asserts approval, so an unreviewed issue parked there is a false claim that someone signed off on it. Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01**. Derive the level with the rubric in [Setting issue priority](#setting-issue-priority); don't eyeball it. -| Priority | Option ID | Rubric total | -| --- | --- | --- | -| Urgent | `79628723` | 12+ | -| High | `0a877460` | 9–11 | -| Medium | `da944a9c` | 6–8 | -| Low | `d67ac7ce` | ≤5 | +| Priority | Option ID | Rubric total | +| -------- | ---------- | ------------ | +| Urgent | `79628723` | 12+ | +| High | `0a877460` | 9–11 | +| Medium | `da944a9c` | 6–8 | +| Low | `d67ac7ce` | ≤5 | -> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) *and* invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: -> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on *other* options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). +> ⚠️ **Never add, rename, or remove an option on a single-select board field (Status or Priority) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans that field's value on every card on the board** (all items go blank for the field you edited — Status if you were editing Status, Priority if you were editing Priority) _and_ invalidates every option id in that field's table above. This has happened once, on Status (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: +> +> 1. **Add or rename an option in the GitHub web UI** (Project #28 → the field's settings). This preserves ids of untouched options and never orphans the cards on _other_ options. ⚠️ **Deleting is different, in the UI as much as in the API: removing an option blanks that field's value on every card that held it, with no undo and no warning that says so.** Before deleting any option, snapshot the board (see recovery below). > 2. If you must script it, first `gh api graphql` the current options **with their `id`s**, then call `updateProjectV2Field` echoing back every existing option **including its `id`**, appending only the new one. `ProjectV2SingleSelectFieldOptionInput.id` is an optional `String`, so a mixed list works: echo the `id` for every option that already exists, omit it only for the one being added. Verify afterward that no card lost its value — snapshot `gh project item-list … --format json` before and after and diff, don't just spot-check. > > Both the `Incoming` Status option and the Urgent/High/Medium/Low `Priority` options were added this way (#1891), with the before/after diff confirming all 264 cards kept their Status. @@ -329,7 +335,7 @@ Priority option IDs (`--single-select-option-id`) — **last verified 2026-08-01 > > This has now happened twice — once via the API (~197 items, reconstructed by inference) and once via the UI (the `Done` column, 247 items, restored from a snapshot in minutes). With a snapshot the recovery is mechanical. > -> **The recipe below is written for a deleted *Status* option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. +> **The recipe below is written for a deleted _Status_ option** — it reads `.status` and writes the Status field id. For a deleted **Priority** option it is the same three steps with two substitutions: read `.priority` instead of `.status` (`gh project item-list --format json` exposes each single-select field under its lowercased name, so both keys are present), and pass the Priority field id `PVTSSF_lADOCt2Azc4BJVxtzg5iJE4` instead of the Status one. Everything else — the snapshot, the grouping safety check, the new-id caveat — applies unchanged. > > ```sh > # 1. Which cards lost their value, and what did they hold? @@ -390,20 +396,20 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i The v1 line takes **security fixes only**, so this board sees little traffic — but a v1 issue still gets a card, and it starts in **Incoming** like a v2 one. Board #11 is a separate org project with **its own ids**; none of the #28 ids above work here. -| Thing | ID | -| --- | --- | -| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | +| Thing | ID | +| --------------- | -------------------------------- | +| Project node ID | `PVT_kwDOCt2Azc4BA5sz` | | Status field ID | `PVTSSF_lADOCt2Azc4BA5szzgzkS-g` | Status option IDs — **last verified 2026-08-01**. -| Status | Option ID | -| --- | --- | -| Incoming | `831820cf` | -| Todo | `f75ad846` | +| Status | Option ID | +| ----------- | ---------- | +| Incoming | `831820cf` | +| Todo | `f75ad846` | | In Progress | `47fc9ee4` | -| In Review | `0439b2bf` | -| Done | `98236657` | +| In Review | `0439b2bf` | +| Done | `98236657` | There is **no Priority field on this board** — the priority rubric applies to v2 only. Don't try to set one here; the field id doesn't exist. @@ -416,6 +422,7 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BA5sz --id "$ITEM_ID" --field-i The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe above apply to **this board too** — same mutation, same failure mode, different ids. Note that three cards on #11 already carry no Status; that predates the `Incoming` addition (verified by before/after diff on 2026-08-01) and is not evidence of an orphaning event. ### Always test new or modified code + - Ensure all code has corresponding tests - Ensure test coverage for each file is at least 90% - In unit tests that expect error output, suppress it from the console @@ -433,37 +440,40 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **TUI** (`clients/tui`): the gate now covers **all of `src/**`, React surface included** — the former interim exclusion of the Ink components, `App.tsx`, and `hooks/` was lifted in #1501. Components mount through `ink-testing-library` with the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`, `App.tsx` mounts against a controllable mock of the `@inspector/core` surface, and keypresses are driven through stdin. The **only** coverage exclusion left in `clients/tui/vitest.config.ts` is `src/tui-servers.ts` — a pure re-export + type alias of core's server resolver with no runtime statements of its own (the logic is measured in `core/` via the web suite; `tui-servers.test.ts` still exercises it behaviorally, and it's excluded only so it doesn't surface as a misleading 0/0 row). Any new logic under `clients/tui/src`, React or not, is held to the gate automatically. - Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`. - Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`. -- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.* +- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: _tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`._ - **Side-by-side (`<Name>.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - - **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside. + - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent *enter* (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the *real* clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews + - When asked to respond to a code review of a PR, - it is not necessary to implement all suggestions - you are free to implement suggestions in a different way or to ignore if there is a good reason - after making the changes, respond to each review comment with what was done (or why it was ignored) ### Mandatory pre-push gate + - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). - ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). + - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. + - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. -- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a *class* (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first *call* into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A *synchronous* such throw fires `pageerror`; its *async* twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never *called* ships a harmless `{}` and is invisible here by design. Every *other* console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). -- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe *and* fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would *not* have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. -- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's *browser-externalization warning* (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the *called-at-init* case (which `smoke:web:browser` also catches, but later/at runtime) **and** the *imported-but-never-called* case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin *records* the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. +- `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). +- `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. +- **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. ### Typescript instructions + - Use TypeScript for all new code - Follow TypeScript best practices and coding standards - NEVER use 'any' as a type @@ -482,7 +492,7 @@ The web client keeps two grab-bag directories under `clients/web/src`, split by - **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`: - _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles. - _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn). - - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's *stateful runtime*, not merely importing from it. + - _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's _stateful runtime_, not merely importing from it. - **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.) The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's now purely the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). The last plain-`.ts` domain type there, the dead `navigation.ts` `InspectorTab`, was removed in #1785, so a pure domain type belongs in `utils/`, not `src/types/`. @@ -492,6 +502,7 @@ Cross-directory imports point **one way, `lib → utils`** (infra depends on pur Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`hooks`/`theme`/`lib`/`utils`/`server` (plus the `core/*` runtime; `hooks` and `theme` were added in #1787), so a module placed **outside** those directories (`types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. The **deliberate, documented** top-level-file exceptions are `src/App.tsx` — a ~4.5k-line composition root at ~42% branch coverage (gating it is a dedicated testing/decomposition effort, not a whitelist tweak) — and the `src/main.tsx` / `src/index.ts` bootstraps (browser `createRoot` render and the bin `runWeb` re-export, the analog of `clients/cli`'s excluded `src/index.ts`). All three are called out in a comment on the `include` array itself rather than left silent. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs. ## React instructions + - UI Components - We are using the Mantine component library for UI. - Instructions are at https://mantine.dev/llms.txt @@ -507,7 +518,7 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - NEVER use inline code; instead extract to functions in the same file, exported or located in a shared location if immediately reusable. - In a component's file, for sub-components: - ALWAYS use Mantine components for layout and content, configured with props for styling and behavior. - - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A *static* prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `<Checkbox label="…" description="…">` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. + - ALWAYS declare a meaningfully named subcomponent as a constant using `.withProps()` if an inline Mantine element carries two or more **static** props. A _static_ prop is one whose value is a literal that configures the element's **styling, layout, or behavior** (`size="sm"`, `c="dimmed"`, `fw={500}`, `gap="xs"`, `justify="space-between"`, `variant="light"`, `withBorder`, `readOnly`, `striped`, …); dynamic props (`value`, `onChange`/`on*`, `children`, `key`, `ref`, and anything whose value is a variable/expression) do **not** count toward the two and are passed at the call site, not baked into the constant. Purely per-instance **content/accessibility** literals — `label`, `description`, `placeholder`, `title`, `aria-label`, `role` — likewise do **not** count toward the two (a `<Checkbox label="…" description="…">` with no styling/layout/behavior props stays inline); they may be baked into a constant when it already qualifies and doing so aids reuse, but they never by themselves trigger extraction. This rule applies in **all** cases: "repeated pattern" is NOT the bar — a single-use element with two or more static styling/layout/behavior props must still be extracted. Bake the static props into the `.withProps()` constant and pass the dynamic ones where it's rendered. - The following **cannot** be expressed via `.withProps()` and so stay inline (like `Box` below), each with a one-line comment saying why: **`Accordion`** (a compound, `multiple`-discriminated generic — `.withProps({ multiple: true, … })` loses its JSX call signature and fails to type); **headless, non-`factory()` Mantine components** such as **`Transition`** (plain function components with no Styles API — they have no `.withProps` static at all, e.g. `Transition.withProps` is a TS2339); and **`data-*` attributes** (not part of a component's typed props object, so excess-property-checked out of a `withProps` literal — pass them at the call site). The rule targets factory-based (Styles-API) Mantine components; anything that isn't one is out of scope entirely — a third-party element (a `react-icons` glyph, another library's component) **and** a first-party component that isn't a Mantine factory (a dumb `export function` like `ContentViewer`, which has no `.withProps` static of its own). - NEVER use `Box` for subcomponent constants — `Box` does not support `.withProps()`. Use `Group`, `Stack`, `Flex`, `Text`, `Paper`, `UnstyledButton`, or `Image` instead. Pick the component that best matches the purpose: `Paper` for bordered/surfaced containers, `Text` for any text or content wrapper, `Stack`/`Group`/`Flex` for layout. A `Box` that genuinely needs a non-flex primitive it can't provide — `component="iframe"`, or `display="grid"` (no Mantine flex primitive is a CSS grid) — stays a `Box` inline, with a one-line comment saying why. - NEVER use a CSS class on a subcomponent constant when the styles can be expressed as a Mantine theme variant instead. Define variants in `src/theme/<Component>.ts` using `Component.extend({ styles: (_theme, props) => { ... } })` and reference them with `variant="variantName"` on the component or in `.withProps()`. @@ -515,32 +526,32 @@ Nothing **enforces** the boundary: no path alias keys off it, and the coverage ` - When a theme variant needs a CSS class for nested/pseudo selectors, use `classNames` in the theme extension to auto-assign it — never add `className` manually in JSX for theme-styled components. - Example — subcomponent constant with `withProps`: ```tsx - const CardContent = Group.withProps({ - flex: 1, - align: 'flex-start', - justify: 'space-between', - wrap: 'nowrap', - }); - return <CardContent> ... </CardContent> + const CardContent = Group.withProps({ + flex: 1, + align: "flex-start", + justify: "space-between", + wrap: "nowrap", + }); + return <CardContent> ... </CardContent>; ``` - Example — theme variant with auto-assigned className for nested selectors: ```tsx - // src/theme/Paper.ts - export const ThemePaper = Paper.extend({ - classNames: (_theme, props) => { - if (props.variant === 'message') return { root: 'message' }; - return {}; - }, - styles: (_theme, props) => { - if (props.variant === 'message') { - return { root: { padding: '1.5rem', borderRadius: 12 } }; - } - return { root: {} }; - }, - }), + // src/theme/Paper.ts + export const ThemePaper = Paper.extend({ + classNames: (_theme, props) => { + if (props.variant === "message") return { root: "message" }; + return {}; + }, + styles: (_theme, props) => { + if (props.variant === "message") { + return { root: { padding: "1.5rem", borderRadius: 12 } }; + } + return { root: {} }; + }, + }); - // Component.tsx - const MessageContainer = Paper.withProps({ variant: 'message' }); + // Component.tsx + const MessageContainer = Paper.withProps({ variant: "message" }); ``` - State and effects - **NEVER reset or re-sync local state from a prop inside a `useEffect`.** `useEffect(() => setX(prop), [prop])` renders once with the stale value, paints it, and only then corrects itself — the user sees the wrong frame and React renders twice. It is an error under `react-hooks/set-state-in-effect`, which the web client's `eslint-plugin-react-hooks` recommended set enforces. @@ -563,4 +574,3 @@ The dev/prod web backend protects every `/api/*` route with `x-mcp-remote-auth: 3. `sessionStorage` — backstop for navigations that land without either of the above. Injection is a no-op when auth is disabled (`DANGEROUSLY_OMIT_AUTH`), and the global name is the shared `INSPECTOR_API_TOKEN_GLOBAL` constant in `core/mcp/remote/constants.ts`. - diff --git a/README.md b/README.md index 13792764..b5d5d7a4 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ npx @modelcontextprotocol/inspector --cli # CLI npx @modelcontextprotocol/inspector --tui # TUI ``` +> **Upgrading from v1?** Read the [v1 → v2 migration guide](./docs/v1-to-v2-migration.md) — CLI flags, the new `--config` vs. `--catalog` split, the Node engine bump, and what no longer ships. + > **Repo status.** This is the **v2** line of the Inspector. Active development happens on **`v2/main`** (the develop branch — all v2 PRs target it), which is merged into **`main`** at milestone releases; `main` is the default branch and holds the latest released v2, published to the npm `latest` tag. The legacy **v1** line lives on **`v1/main`** — security fixes only, published straight from that branch to the npm `v1-latest` tag (`npx @modelcontextprotocol/inspector@v1-latest`). See [`AGENTS.md`](./AGENTS.md) for branch/board conventions. ## Project layout @@ -38,7 +40,7 @@ inspector/ │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests ├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) -├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) +├── docs/ # Task-oriented guides (v1→v2 migration, server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) └── README.md # You are here @@ -49,6 +51,7 @@ Each client has its own README with client-specific detail: Task-oriented guides live under [`docs/`](./docs): +- [Migrating from v1 to v2](./docs/v1-to-v2-migration.md) — the v1 → v2 map: CLI flag mapping, `--config` vs. `--catalog` semantics with before/after examples, the Node engine bump (`>=22.7.5` → `>=22.19.0`), env-var renames, and the sub-packages that no longer ship. - [MCP server configuration](./docs/mcp-server-configuration.md) — which server(s) the Inspector connects to: `--catalog` vs. `--config`, ad-hoc targets, the `--` separator, the file format and its Inspector-specific per-server fields. Shared by all three clients; the cli and tui READMEs delegate their server-options sections to it. - [Reviewing an MCP App](./docs/mcp-app-review.md) — the CLI-first → one-shot-web recipe for automated App-tool review: `--app-info` probe → deep-link navigate → rendered widget, plus OAuth handoff and proxy support. - [Launcher and config consolidation](./docs/launcher-config-consolidation-plan.md) — why the launcher runs a client in-process rather than spawning it, and how the shared config processor fits in. @@ -130,18 +133,18 @@ This is what lets an Inspector connection negotiating `protocolEra: "auto" | "mo Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -| Config | Demonstrates | Issue | -| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| Config | Demonstrates | Issue | +| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | -| `modern-mrtr-http.json` | A single MRTR round-trip | — | -| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | -| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | -| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | -| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | -| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | -| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | -| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | -| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +| `modern-mrtr-http.json` | A single MRTR round-trip | — | +| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | +| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | +| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | +| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | +| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | +| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | +| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | #### MCP Apps @@ -261,17 +264,17 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate). -| Script | What it does | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | -| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | -| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | -| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | -| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | +| Script | What it does | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | +| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards _this_ gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. @@ -287,7 +290,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. -- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved *relative to* `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. +- **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball diff --git a/clients/cli/README.md b/clients/cli/README.md index b3da972b..d5c033df 100644 --- a/clients/cli/README.md +++ b/clients/cli/README.md @@ -12,6 +12,8 @@ npx @modelcontextprotocol/inspector --cli node build/index.js Supports tools, resources, and prompts (plus `--method servers/list` / `servers/show` for catalog entries without connecting). +> Coming from the v1 CLI? See the [v1 → v2 migration guide](../../docs/v1-to-v2-migration.md) — every v1 flag still exists, but exit codes, argument ordering, and the `--` separator changed. + ### Examples **Basic usage** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 98336df1..88250cd6 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -201,5 +201,6 @@ The CLI and TUI do not perform catalog CRUD yet — they are read consumers — ## Related +- [Migrating from v1 to v2](./v1-to-v2-migration.md) — why `--config` means something narrower than it did in v1, and what `--catalog` replaced. - [Launcher and config consolidation](./launcher-config-consolidation-plan.md) — how the launcher and the shared config processor fit together. - [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md new file mode 100644 index 00000000..ef2865fc --- /dev/null +++ b/docs/v1-to-v2-migration.md @@ -0,0 +1,325 @@ +# Migrating from Inspector v1 to v2 + +v2 is a rewrite. The package name and the `npx @modelcontextprotocol/inspector` entry point are unchanged, but the architecture, the CLI surface, and the way you point the Inspector at a server all changed. + +This guide is the map. It covers what breaks, what it becomes, and the handful of places where a v1 command still runs in v2 but means something different — the ones worth reading before you conclude v2 is broken. + +> **v1 is deprecated** and receives security fixes only, published to the `v1-latest` npm dist-tag. To stay on it, pin it explicitly: +> +> ```bash +> npx @modelcontextprotocol/inspector@v1-latest +> ``` + +## At a glance + +| | v1 | v2 | +| ----------------- | ------------------------------------------------ | --------------------------------------------------------------- | +| Node | `>=22.7.5` | **`>=22.19.0`** | +| npm packages | 4 (`inspector` + `-client` / `-server` / `-cli`) | **1** (`@modelcontextprotocol/inspector`) | +| Processes / ports | web UI on `6274` **+ MCP proxy on `6277`** | one web server on `6274` (plus a dynamic MCP Apps sandbox port) | +| Modes | web, `--cli` | web, `--cli`, **`--tui`** | +| Server list | per-browser, in `localStorage` | a **catalog file** on disk (`~/.mcp-inspector/mcp.json`) | +| Auth token env | `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | +| CLI exit codes | `0` / `1` | `0`–`5`, plus a JSON error envelope on stderr | + +The three changes most likely to bite an existing setup: + +1. **`--config` no longer means what it did** — see [`--config` vs `--catalog`](#--config-vs---catalog). +2. **A failing tool call now exits non-zero** (`5`), where v1 exited `0`. CI scripts that ran `&&` chains past a failed call will start failing — correctly. +3. **`SERVER_PORT` was the proxy port and now isn't** — it survives as a fallback for the MCP Apps sandbox port. Setting it no longer moves anything you can browse. + +## Requirements + +v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. `npx` will refuse to run on an older Node rather than fail obscurely later. + +## What no longer ships + +v1 published four packages: + +- `@modelcontextprotocol/inspector` +- `@modelcontextprotocol/inspector-client` +- `@modelcontextprotocol/inspector-server` +- `@modelcontextprotocol/inspector-cli` + +**v2 publishes only the first.** It is a single tarball with a single version number, containing the web client, the CLI, the TUI, and the launcher that dispatches between them. The three sub-packages are frozen at `1.0.1`, deprecated on npm, and will never see a 2.x. + +If you depend on one of them directly, drop it and use the root package's `mcp-inspector` bin. There is no v2 equivalent of importing `inspector-server` as a library; the shared runtime lives in this repo's `core/` and is not published separately yet ([#1636](https://github.com/modelcontextprotocol/inspector/issues/1636)). + +```diff + { + "devDependencies": { +- "@modelcontextprotocol/inspector-cli": "^1.0.1" ++ "@modelcontextprotocol/inspector": "^2.0.0" + } + } +``` + +## Architecture: the proxy is gone + +v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. + +v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. There is no second port to expose, forward, or allow through a firewall. + +Consequences: + +- `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) +- `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. +- Docker only needs `-p 6274:6274`. The v1 recipe published `6277` as well. + +## Launching + +```bash +npx @modelcontextprotocol/inspector # web UI (unchanged default) +npx @modelcontextprotocol/inspector --cli # CLI +npx @modelcontextprotocol/inspector --tui # TUI (new in v2) +``` + +The mode flag must come **first**, immediately after the binary name; everything after it is forwarded to that client unchanged. + +Passing a server ad-hoc works the same way it did: + +```bash +npx @modelcontextprotocol/inspector node build/index.js +npx @modelcontextprotocol/inspector -e KEY=value -- node build/index.js --server-flag +``` + +## `--config` vs `--catalog` + +**This is the change most likely to surprise you.** Both versions have a `--config` flag, both take an `mcpServers` file, and they do not mean the same thing. + +In v1, `--config path.json --server name` resolved one entry out of the file and launched against it. The file was read once and never written — but there was no other file, because v1's web UI kept its server list in browser `localStorage`. + +v2 has a first-class **server catalog** on disk, and splits the two roles: + +| | `--catalog <path>` | `--config <path>` | +| ------------------------- | -------------------------------------------------- | -------------------------------------------------------- | +| Writable by the Inspector | **Yes** — this is the Inspector's own list | **No.** Served as-is; never written, seeded, or migrated | +| When the file is missing | created and seeded | **errors** | +| Default path | `~/.mcp-inspector/mcp.json`, or `MCP_CATALOG_PATH` | none — must be passed | +| Editable in the web UI | yes | no (catalog CRUD is hidden) | + +So: + +- **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. +- **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. +- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no such default — it started empty every time. + +### Before / after + +**Your v1 file works unchanged.** The format is the same `mcpServers` shape, `type: "streamable-http"` is still accepted (as is the `"http"` alias), and unknown fields such as v1's exported `note` are carried through rather than rejected. + +```bash +# v1 — one entry out of a file +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything + +# v2 — same thing, same read-only guarantee +npx @modelcontextprotocol/inspector --config ./mcp.json --server everything +``` + +⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI you pick the server from the list after it loads. + +```bash +# v2 — let the Inspector manage the file (web UI can edit it) +npx @modelcontextprotocol/inspector --catalog ./mcp.json + +# v2 — the default catalog, no flags at all +npx @modelcontextprotocol/inspector +``` + +**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. + +If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. + +The full model, including the Inspector-specific per-server fields v2 adds (`protocolEra`, `roots`, `requestTimeout`, `oauth`, …), is documented in [MCP server configuration](./mcp-server-configuration.md). + +## CLI flag mapping + +Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renamed or removed: + +| v1 flag | v2 | Notes | +| -------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- | +| `--method <method>` | same | v2 adds `initialize`, `servers/list`, `servers/show`; stream-only methods are rejected explicitly | +| `--tool-name <name>` | same | | +| `--tool-arg <k=v>` | same | | +| `--uri <uri>` | same | | +| `--prompt-name <name>` | same | | +| `--prompt-args <k=v>` | same | | +| `--log-level <level>` | same | | +| `--transport <sse\|http\|stdio>` | same | | +| `--server-url <url>` | same | | +| `--header "Name: Value"` | same | | +| `--metadata <k=v>` | same | | +| `--tool-metadata <k=v>` | same | | +| `-e KEY=VALUE` | same | | +| `--config <path>` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | +| `--server <name>` | same | now `--cli`-only | +| `[target...]` | same | but see the target-ordering rule below | + +New in v2, with no v1 equivalent: + +| Flag | What it does | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `--catalog <path>` | writable catalog file (see above) | +| `--cwd <path>` | working directory for a stdio server process | +| `--tool-args-json '<json>'` | tool arguments as one JSON object, passed verbatim (no `key=value` coercion, so `"012"` stays a string) | +| `--format text\|json` | `json` emits a single `{"result":…}` object on stdout with no banners | +| `--app-info` | probe a tool's MCP App UI metadata without invoking the tool | +| `--connect-timeout <ms>` | connect timeout; defaults to `15000` for ad-hoc targets so a black-holed host fails fast | +| `--client-id` / `--client-secret` / `--client-metadata-url` / `--client-config` | OAuth client configuration | +| `--callback-url <url>` | OAuth loopback redirect URI (default `http://127.0.0.1:6276/oauth/callback`) | +| `--relogin` / `--stored-auth-only` / `--use-stored-auth` / `--wait-for-auth` / `--list-stored-auth` / `--print-handoff` | OAuth token reuse and web→CLI handoff | + +v1 had no OAuth support in the CLI at all; a protected server simply failed. If you script against protected servers, `--stored-auth-only` is the flag to reach for in CI — it never opens a browser and fails fast with `auth_required` instead of hanging on a loopback callback. + +See the [CLI README](../clients/cli/README.md) for the full surface. + +## CLI behavior changes + +The flags survived; three behaviors did not. + +### 1. Exit codes and the error envelope + +v1 exited `0` on success and `1` on any failure — including a `tools/call` that came back `isError: true`, which exited `0` because the _call_ succeeded. + +v2 maps failures onto a stable set: + +| Code | Meaning | +| ---- | ------------------------------------------------------- | +| `0` | Success | +| `1` | Usage / unexpected error | +| `2` | No MCP App on the tool (`--app-info` probe) | +| `3` | Server requires authentication | +| `4` | Server unreachable (DNS, refused, timeout) | +| `5` | Tool error — `isError: true`, or the tool was not found | + +and writes one JSON line to **stderr** on any non-zero exit: + +```json +{ + "error": { + "code": "auth_required", + "message": "Unauthorized", + "status": 401, + "url": "https://api.example/mcp" + } +} +``` + +**Migration note:** a CI step that chained `inspector --cli … --method tools/call … && next-step` silently continued past failing tool calls in v1. In v2 it stops. That is the intended behavior, but it will surface as "v2 broke my pipeline" the first time a tool was failing all along. Parse the class with `2>&1 | tail -1 | jq .error` rather than matching on prose. + +### 2. The target must come first + +v2's CLI reads the leading run of non-dash tokens as the server target. Anything after the first flag is no longer part of it: + +```bash +mcp-inspector --cli node build/index.js --method tools/list # ✅ +mcp-inspector --cli --method tools/list node build/index.js # ❌ target silently dropped +``` + +The second form does **not** error — the target is discarded and the Inspector falls back to your catalog, so it appears to work against the wrong server. v1 tolerated either order. + +### 3. `--` splits the other way under `--cli` + +On **web and TUI**, everything _after_ `--` goes to the target command — same as v1: + +```bash +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose +``` + +Under **`--cli`** it is reversed: everything _before_ `--` is the target, everything _after_ is the Inspector's own options. + +```bash +mcp-inspector --cli node build/index.js -- --method tools/list +``` + +So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. + +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. + +## Environment variables + +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working | +| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | +| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | +| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | +| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | +| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | +| `DANGEROUSLY_OMIT_AUTH` | same | | +| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | +| — | `MCP_CATALOG_PATH` | Default catalog path | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | +| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | + +### The v1 UI configuration settings + +v1 exposed a **Configuration** panel writing `localStorage` keys, also settable by query param. Those were global; in v2 the equivalents are **per-server settings**, stored in the catalog entry and editable in Server Settings: + +| v1 setting | v2 | +| --------------------------------------- | ----------------------------------- | +| `MCP_SERVER_REQUEST_TIMEOUT` | per-server `requestTimeout` (ms) | +| _(no v1 equivalent)_ | per-server `connectionTimeout` (ms) | +| `MCP_REQUEST_TIMEOUT_RESET_ON_PROGRESS` | always on; no longer configurable | +| `MCP_REQUEST_MAX_TOTAL_TIMEOUT` | no equivalent | +| `MCP_PROXY_FULL_ADDRESS` | no equivalent (no proxy) | + +## Web UI + +**Server list.** v1 kept it in browser `localStorage`, so it was per-browser and invisible to the CLI. v2 keeps it in the catalog file, shared by all three clients — add a server in the web UI and the CLI and TUI see it. + +**Query params.** v1 accepted `?transport=…&serverUrl=…&serverCommand=…&serverArgs=…` plus `?MCP_PROXY_AUTH_TOKEN=…`. v2's deep link is narrower and gated: + +``` +http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token> +``` + +- `serverCommand` / `serverArgs` are gone — a URL can no longer ask the Inspector to spawn a process. +- `serverUrl` is restricted to `http:` / `https:`. +- `autoConnect` must equal the per-launch `MCP_INSPECTOR_API_TOKEN`, so a third-party-minted link can't drive a connect. +- `?MCP_INSPECTOR_API_TOKEN=…` replaces `?MCP_PROXY_AUTH_TOKEN=…`. + +Three further params (`openApp`, `appArgs`, `autoOpen`) land you on a rendered MCP App; see the [web README](../clients/web/README.md#deep-link-auto-connect). + +**Auth.** v1's bearer-token field in the sidebar is replaced by per-server `headers` and real OAuth (including CIMD and enterprise-managed auth) configured in Server Settings and persisted in the catalog. + +## Docker + +```bash +# v1 +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ + -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ + ghcr.io/modelcontextprotocol/inspector:1.0.1 + +# v2 — one port; the image already sets the wildcard-bind opt-in +docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +``` + +Notes: + +- The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. +- **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. +- The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. +- `:latest` now points at v2. To stay on the v1 image, pin its exact version tag (`:1.0.1`). + +## Troubleshooting + +**"`--config` errors that my file doesn't exist, but v1 created it."** v1 never created config files either — but v2's _default_ path is a catalog it will create. If you want a file created on demand, pass it as `--catalog`, not `--config`. + +**"`--server` is ignored."** It selects only under `--cli`. Web warns and ignores it; the TUI errors on it as unknown. + +**"My CI step started failing after upgrading."** Most likely exit code `5`: a `tools/call` returning `isError: true` now exits non-zero. Check `2>&1 | tail -1 | jq .error`. + +**"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. + +**"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). + +**"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. + +## Related + +- [MCP server configuration](./mcp-server-configuration.md) — the full `--catalog` / `--config` / ad-hoc model. +- [CLI README](../clients/cli/README.md) · [TUI README](../clients/tui/README.md) · [web README](../clients/web/README.md) +- [Reviewing an MCP App](./mcp-app-review.md) — the CLI-first App review recipe. From 6f535356b452871a8e6cf76837ad64a4b2481e39 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 19:56:38 -0400 Subject: [PATCH 043/941] fix: key tool rows by source position so duplicate names filter correctly `ToolControls` keyed both the main list and the SEP-2243 excluded list by `tool.name`. A server may legitimately return the same tool name more than once, and colliding React keys let a filtered-out row survive reconciliation: searching narrows the array, React matches the surviving rows against the stale keys, and an unrelated row stays mounted (plus a "two children with the same key" console warning). Stamp each row's position in the unfiltered list before filtering and use `<index>:<name>` as the key, on both lists. The position is captured before the filter, so the key stays stable as the search narrows. No deduplication, no change to what `tools/list` returns, and the name/title search behavior is unchanged. Adds regression tests for both lists using the reporter's fixture. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../groups/ToolControls/ToolControls.test.tsx | 85 +++++++++++++++++++ .../groups/ToolControls/ToolControls.tsx | 45 ++++++---- 2 files changed, 112 insertions(+), 18 deletions(-) diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index 1ce40d1f..ef3d0210 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -204,6 +204,91 @@ describe("ToolControls", () => { expect(onRefreshList).toHaveBeenCalledTimes(1); }); + // A server may legitimately return the same tool name twice; keying rows by + // name alone collides, and React then reuses the stale row instead of + // unmounting it when the filter narrows (#1957). + const duplicateNameTools: Tool[] = [ + { + name: "get_record", + title: "Get Record First", + inputSchema: { type: "object" }, + }, + { + name: "duplicate_tool", + title: "First Duplicate", + inputSchema: { type: "object" }, + }, + { + name: "unrelated_tool", + title: "Unrelated Tool First", + inputSchema: { type: "object" }, + }, + { + name: "duplicate_tool", + title: "Second Duplicate", + inputSchema: { type: "object" }, + }, + { + name: "get_record", + title: "Get Record Second", + inputSchema: { type: "object" }, + }, + { + name: "unrelated_tool", + title: "Unrelated Tool Second", + inputSchema: { type: "object" }, + }, + ]; + + it("removes every non-matching row when tool names repeat (#1957)", async () => { + const user = userEvent.setup(); + renderWithMantine(<ControlledToolControls tools={duplicateNameTools} />); + + await user.type(screen.getByPlaceholderText("Search tools..."), "get"); + + expect(screen.getByText("Get Record First")).toBeInTheDocument(); + expect(screen.getByText("Get Record Second")).toBeInTheDocument(); + for (const stale of [ + "First Duplicate", + "Second Duplicate", + "Unrelated Tool First", + "Unrelated Tool Second", + ]) { + expect(screen.queryByText(stale)).not.toBeInTheDocument(); + } + }); + + it("removes every non-matching excluded row when names repeat (#1957)", async () => { + const user = userEvent.setup(); + const duplicateExcluded = [ + { + tool: { name: "get_thing", inputSchema: { type: "object" as const } }, + reason: "a", + }, + { + tool: { name: "dupe", inputSchema: { type: "object" as const } }, + reason: "b", + }, + { + tool: { name: "dupe", inputSchema: { type: "object" as const } }, + reason: "c", + }, + { + tool: { name: "get_other", inputSchema: { type: "object" as const } }, + reason: "d", + }, + ]; + renderWithMantine( + <ControlledToolControls tools={[]} excludedTools={duplicateExcluded} />, + ); + + await user.type(screen.getByPlaceholderText("Search tools..."), "get"); + + expect(screen.getByText("get_thing")).toBeInTheDocument(); + expect(screen.getByText("get_other")).toBeInTheDocument(); + expect(screen.queryByText("dupe")).not.toBeInTheDocument(); + }); + it("renders no load error by default", () => { renderWithMantine(<ToolControls {...baseProps} />); expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 1806b0c3..4ad70d85 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -108,6 +108,18 @@ const ExcludedTooltip = Tooltip.withProps({ position: "right", }); +// A server may return the same tool name more than once, so the name alone is +// not a unique React key — colliding keys let a filtered-out row survive +// reconciliation instead of unmounting (#1957). The tool's position in the +// unfiltered list disambiguates duplicates and stays stable while the search +// narrows, since it is captured before filtering. +const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`; + +/** Matches a tool against the (already lower-cased) search query by name or title. */ +const matchesQuery = (tool: Tool, query: string) => + tool.name.toLowerCase().includes(query) || + (tool.title?.toLowerCase().includes(query) ?? false); + export function ToolControls({ tools, excludedTools = [], @@ -122,22 +134,19 @@ export function ToolControls({ }: ToolControlsProps) { const viewportRef = useScrollMemory("tools-sidebar"); const query = searchText.toLowerCase(); - const filteredTools = searchText - ? tools.filter( - (tool) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : tools; + // Stamp each row's source position before filtering, so the key survives the + // list narrowing (#1957). + const filteredTools = tools + .map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) })) + .filter(({ tool }) => !searchText || matchesQuery(tool, query)); // Excluded tools are searchable too, matching name AND title like the main // list above, so a filtered view stays consistent. - const filteredExcluded = searchText - ? excludedTools.filter( - ({ tool }) => - tool.name.toLowerCase().includes(query) || - (tool.title?.toLowerCase().includes(query) ?? false), - ) - : excludedTools; + const filteredExcluded = excludedTools + .map((excluded, sourceIndex) => ({ + ...excluded, + key: rowKey(excluded.tool.name, sourceIndex), + })) + .filter(({ tool }) => !searchText || matchesQuery(tool, query)); return ( <SidebarStack> @@ -156,9 +165,9 @@ export function ToolControls({ <ListLoadError error={loadError} what="tools" onRetry={onRefreshList} /> <SidebarScroll viewportRef={viewportRef}> <Stack gap="xs"> - {filteredTools.map((tool) => ( + {filteredTools.map(({ tool, key }) => ( <ToolListItem - key={tool.name} + key={key} tool={tool} selected={tool.name === selectedName} onClick={() => { @@ -169,8 +178,8 @@ export function ToolControls({ {filteredExcluded.length > 0 && ( <> <ExcludedDivider /> - {filteredExcluded.map(({ tool, reason }) => ( - <ExcludedTooltip key={tool.name} label={reason}> + {filteredExcluded.map(({ tool, reason, key }) => ( + <ExcludedTooltip key={key} label={reason}> <ExcludedRow> <ExcludedWarningIcon> <RiErrorWarningLine /> From 4340283f7030d6a6df77b84c98d42a5a1571eeb0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:22:47 -0400 Subject: [PATCH 044/941] test: add a duplicate-tool-names test server for the #1957 repro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the repo could serve the shape this fix is about: every preset registers a unique name, and the SDK's `registerTool` rejects a repeat. So the only reproduction was a unit test — there was no way to smoke the real app against a server that returns duplicates. Adds `ServerConfig.duplicateToolNames` (a list of registered tool names) to the composable test server, plumbed through the JSON config, and a showcase config `duplicate-tool-names-http.json`. The named tools are emitted a second time in `tools/list` with the same `name` and a "(duplicate)" title. The repeats are appended after the whole list rather than placed beside their twin. That is both the realistic shape (two tool sources concatenated) and what makes the defect observable: React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the bug hides. Only a separated pair collides in the keyed map and orphans a row — verified both ways against the prod bundle. The `tools/list` override is shared with the existing pagination handler (which already hand-builds the list); with no `maxPageSize` it returns a single unbounded page. Duplication happens before pagination, so a duplicated pair can straddle a page boundary the way a real server's would. Also adds a ToolControls test for this appended ordering, distinct from the reporter's interleaved fixture, and documents the config in the README showcase table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- README.md | 9 +++ .../groups/ToolControls/ToolControls.test.tsx | 38 ++++++++++++ .../configs/duplicate-tool-names-http.json | 17 ++++++ test-servers/src/composable-test-server.ts | 58 +++++++++++++++++-- test-servers/src/load-config.ts | 6 ++ test-servers/src/resolve-config.ts | 1 + 6 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 test-servers/configs/duplicate-tool-names-http.json diff --git a/README.md b/README.md index 13792764..f650dcdf 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | | `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | | `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | | `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | | `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | @@ -206,6 +207,14 @@ Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. +#### Duplicate tool names + +`duplicate-tool-names-http.json` serves `get_weather`, `get_temp`, `echo`, and `add`, then repeats `get_weather` and `echo` at the end of `tools/list` with the same `name` and a `(duplicate)` title (`duplicateToolNames`). No preset can produce this shape — the SDK's `registerTool` rejects a repeated name — but a real server can and does, and the Inspector has to render it faithfully. + +Connect (default legacy era), open the Tools tab, and type `get` into **Search tools**: the list must narrow to exactly the three `get_*` rows. On the broken build it kept a stale `echo` row, because the sidebar keyed rows by `tool.name` alone and the colliding keys orphaned a child during reconciliation ([#1957](https://github.com/modelcontextprotocol/inspector/issues/1957)). + +The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index ef3d0210..b6e379e8 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -258,6 +258,44 @@ describe("ToolControls", () => { } }); + // The shape `test-servers/configs/duplicate-tool-names-http.json` serves: the + // repeats are appended after the whole list rather than sitting beside their + // twin. Distinct from the fixture above and worth its own case — React + // matches a leading run of same-key children first, so where the duplicates + // sit decides which row gets orphaned (#1957). + const appendedDuplicateTools: Tool[] = [ + { name: "get_weather", inputSchema: { type: "object" } }, + { name: "get_temp", inputSchema: { type: "object" } }, + { name: "echo", inputSchema: { type: "object" } }, + { name: "add", inputSchema: { type: "object" } }, + { + name: "get_weather", + title: "get_weather (duplicate)", + inputSchema: { type: "object" }, + }, + { + name: "echo", + title: "echo (duplicate)", + inputSchema: { type: "object" }, + }, + ]; + + it("removes every non-matching row when repeats are appended (#1957)", async () => { + const user = userEvent.setup(); + renderWithMantine( + <ControlledToolControls tools={appendedDuplicateTools} />, + ); + + await user.type(screen.getByPlaceholderText("Search tools..."), "get"); + + expect(screen.getByText("get_temp")).toBeInTheDocument(); + expect(screen.getByText("get_weather (duplicate)")).toBeInTheDocument(); + expect(screen.queryByText("add")).not.toBeInTheDocument(); + // The row the collision orphaned on the broken build. + expect(screen.queryByText("echo")).not.toBeInTheDocument(); + expect(screen.queryByText("echo (duplicate)")).not.toBeInTheDocument(); + }); + it("removes every non-matching excluded row when names repeat (#1957)", async () => { const user = userEvent.setup(); const duplicateExcluded = [ diff --git a/test-servers/configs/duplicate-tool-names-http.json b/test-servers/configs/duplicate-tool-names-http.json new file mode 100644 index 00000000..af1699be --- /dev/null +++ b/test-servers/configs/duplicate-tool-names-http.json @@ -0,0 +1,17 @@ +{ + "serverInfo": { + "name": "duplicate-tool-names", + "version": "1.0.0" + }, + "tools": [ + { "preset": "get_weather" }, + { "preset": "get_temp" }, + { "preset": "echo" }, + { "preset": "add" } + ], + "duplicateToolNames": ["get_weather", "echo"], + "transport": { + "type": "streamable-http", + "port": 3142 + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 4c836e4b..648fad35 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -472,6 +472,22 @@ export interface ServerConfig { resourceTemplates?: number; prompts?: number; }; + /** + * Emit the named registered tools **twice** in `tools/list`, with the same + * `name` on both entries and " (duplicate)" appended to the second's title. + * + * Nothing else in this repo can produce this shape: every preset registers a + * unique name, and the SDK's `registerTool` rejects a repeat. But a real + * server can and does return duplicates, and the Inspector has to render that + * faithfully rather than break on it (#1957 — duplicate names collided in the + * Tools sidebar's React keys, so filtering left unrelated rows on screen). + * + * Deliberately a list of names rather than a blanket flag, so a config can + * duplicate part of its tool set and leave the rest alone — that mix is what + * makes a search filter's behavior legible. A name that isn't registered is + * ignored. + */ + duplicateToolNames?: string[]; /** * Gate a tool's visibility in `tools/list` on a client-declared extension * (SEP-2133 `capabilities.extensions`). Maps extension id → tool name (the @@ -1107,16 +1123,45 @@ export function createMcpServer(config: ServerConfig): McpServer { // Set up pagination handlers if maxPageSize is configured const maxPageSize = config.maxPageSize || {}; - // Tools pagination - if (capabilities.tools && maxPageSize.tools !== undefined) { + // Emit each named tool a second time, same `name`, title marked so the two + // rows are told apart on screen. See ServerConfig.duplicateToolNames (#1957). + // + // The second copies go **after** the whole list rather than beside their + // twin, which is both how a real server produces duplicates (two tool sources + // concatenated) and what makes the defect observable: React's child + // reconciliation walks a matching prefix first, so head-adjacent duplicates + // happen to line up and survive. It is the *separated* pair that collides in + // the keyed map and orphans a row. + const duplicateToolNames = new Set(config.duplicateToolNames ?? []); + const withDuplicates = (tools: Tool[]): Tool[] => + duplicateToolNames.size === 0 + ? tools + : [ + ...tools, + ...tools + .filter((tool) => duplicateToolNames.has(tool.name)) + .map((tool) => ({ + ...tool, + title: `${tool.title ?? tool.name} (duplicate)`, + })), + ]; + + // Tools pagination, and/or the duplicate-name override — both need the same + // hand-built list, so the handler is installed when either is configured. + if ( + capabilities.tools && + (maxPageSize.tools !== undefined || duplicateToolNames.size > 0) + ) { mcpServer.server.setRequestHandler("tools/list", async (request) => { const cursor = request.params?.cursor; - const pageSize = maxPageSize.tools!; + // No pagination configured: one page holding everything, so the duplicate + // override can share this handler without inventing a page size. + const pageSize = maxPageSize.tools ?? Number.MAX_SAFE_INTEGER; // Convert registered tools to Tool format, mirroring the SDK's tools/list. // The input-schema JSON comes from the SDK's memoised converter; the // output-schema JSON is the value the SDK cached at registration. - const allTools: Tool[] = []; + const registeredTools: Tool[] = []; for (const [name, registered] of state.registeredTools.entries()) { if (registered.enabled) { const toolDefinition: Record<string, unknown> = { @@ -1134,9 +1179,12 @@ export function createMcpServer(config: ServerConfig): McpServer { toolDefinition.outputSchema = registered.outputSchemaJson; } - allTools.push(toolDefinition as Tool); + registeredTools.push(toolDefinition as Tool); } } + // Duplicate before paginating, so a duplicated pair can straddle a page + // boundary exactly as a real server's would. + const allTools = withDuplicates(registeredTools); const startIndex = cursor ? parseInt(cursor, 10) : 0; const endIndex = startIndex + pageSize; diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 8e225200..7ee77a84 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -66,6 +66,12 @@ export interface ConfigFile { resourceTemplates?: number; prompts?: number; }; + /** + * Names of registered tools to emit **twice** in `tools/list` (same `name`, + * the second's title marked "(duplicate)") — the nonconforming-but-real shape + * no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957). + */ + duplicateToolNames?: string[]; /** * Gate a tool's `tools/list` visibility on a client-declared extension. Maps * extension id → tool name; the tool appears only when the connected client diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 491bfed6..bd229c94 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -91,6 +91,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig { tasks: config.tasks, tasksExtension: config.tasksExtension, maxPageSize: config.maxPageSize, + duplicateToolNames: config.duplicateToolNames, extensionGatedTools: config.extensionGatedTools, serverType: isHttp ? (transport.type as "sse" | "streamable-http") From 6c33da38c964ae38b4b4e28f8c33fc379951166d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:29:58 -0400 Subject: [PATCH 045/941] chore(deps): align zod across installs and guard against version skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1896 `clients/web`'s `tsc -b` exhausted the 4GB default heap at zod 4.4.x. The cause is not a zod regression nor a pathological inference site of ours: it is two copies of zod at *mismatched versions* inside one tsc program. `clients/web/tsconfig.test.json` compiles `test-servers/src`, whose files resolve zod (and the SDK's zod) from the **root** install, alongside web's own sources, which resolve from `clients/web/node_modules`. At 4.3.6 vs 4.4.3 TypeScript must relate two structurally-distinct declarations of every `@modelcontextprotocol/*` schema type, which is exponential over a recursive generic surface — `TS2589 Type instantiation is excessively deep`, then OOM. Bumping root zod to 4.4.3 and changing nothing else returns the build to baseline: 11.4s and 2,567,882 instantiations, against 11.2s / 2,567,503 at 4.3.6. Two copies at the same version are harmless; skew is what explodes. So drop the `~4.3.6` hold on `clients/web` and move all four manifests and lockfiles to zod 4.4.3 together, and add `verify:dep-lockstep` to `validate` to keep them there. The guard derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and fails deny-by-default on any skew not in the annotated `TOLERATED_SKEW` allowlist. It joins the existing mutual-vouch ring so dropping it from `validate` is caught by `verify:format-coverage`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 1 + AGENTS.md | 3 +- README.md | 7 +- clients/cli/package-lock.json | 2 +- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 2 +- clients/tui/package.json | 2 +- clients/web/package-lock.json | 8 +- clients/web/package.json | 2 +- package-lock.json | 8 +- package.json | 5 +- scripts/verify-dep-lockstep.mjs | 280 +++++++++++++++++++++++++++ scripts/verify-dep-lockstep.test.mjs | 196 +++++++++++++++++++ scripts/verify-format-coverage.mjs | 11 +- 14 files changed, 505 insertions(+), 24 deletions(-) create mode 100644 scripts/verify-dep-lockstep.mjs create mode 100644 scripts/verify-dep-lockstep.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd1..a49cec61 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. +- **A dependency bump must land in every install.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index a4185baa..1d38a6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,11 +448,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`test:scripts`** (the guard's own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 13792764..635d93cf 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ inspector/ │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests -├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, pack:verify) +├── scripts/ # Root build/verify tooling (install cascade, smokes, verify-build-gate, verify-format-coverage, verify-dep-lockstep, pack:verify) ├── docs/ # Task-oriented guides (server configuration, MCP App review, launcher/config plan) ├── specification/ # Design/build specifications ├── AGENTS.md # Contribution rules for agents AND humans (see below) @@ -263,13 +263,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles `core/` and `test-servers/src` — which resolve from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e4..926899e7 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -19,7 +19,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-cli": "build/index.js" diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f3..7dfbad9c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -43,7 +43,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3e..431b60d8 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -20,7 +20,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-tui": "build/index.js" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc..b2ffcc92 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -39,7 +39,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7..11cfb098 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -35,7 +35,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector-web": "build/index.js" @@ -11677,9 +11677,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/clients/web/package.json b/clients/web/package.json index a347376e..d15870e6 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -61,7 +61,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "~4.3.6" + "zod": "^4.4.3" }, "devDependencies": { "@chromatic-com/storybook": "^5.2.1", diff --git a/package-lock.json b/package-lock.json index 58f21e7a..4766aff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -4534,9 +4534,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index d3467cf6..0d353453 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:typecheck-coverage": "node scripts/verify-typecheck-coverage.mjs", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", - "validate": "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run test:scripts && npm run validate:core && npm run validate:web && npm run validate:cli && npm run validate:tui && npm run validate:launcher", + "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", "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", "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", "lint:core": "eslint \"core/**/*.{ts,tsx}\"", "lint:shared": "eslint \"test-servers/src/**/*.{ts,tsx,mts,cts}\" vitest.shared.mts eslint.config.js", @@ -98,7 +99,7 @@ "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs new file mode 100644 index 00000000..496fdc8d --- /dev/null +++ b/scripts/verify-dep-lockstep.mjs @@ -0,0 +1,280 @@ +#!/usr/bin/env node +// Durable guard for the "one version per install-crossing dependency" invariant +// (#1896). v2 is not an npm workspace: the root and each `clients/*` carry their +// own `node_modules`, so the *same* package can resolve to two different +// versions in one process — or, worse, in one `tsc` program. +// +// That second case is what this guard exists for. A client's +// `tsconfig.test.json` compiles first-party sources that live *outside* the +// client (`test-servers/src`, `core/`), and those files resolve their +// dependencies from the **root** install while the client's own sources resolve +// from the client install. When the two copies are the same version the +// duplication is harmless; when they skew, TypeScript must relate two +// structurally-distinct declarations of the same type. +// +// For most packages that is merely redundant work. For a deeply +// recursive-generic type surface it is exponential: zod `4.3.6` (root) against +// zod `4.4.3` (clients/web) made `tsc -b` in `clients/web` exhaust the 4GB +// default heap outright (`FATAL ERROR: Ineffective mark-compacts near heap +// limit`) via `TS2589 Type instantiation is excessively deep`, because every +// `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the +// two copies — changing nothing else — returned the build to its baseline cost. +// +// The candidate set is DERIVED, not hand-listed: it is the packages imported by +// `core/` and `test-servers/src`, the two first-party surfaces compiled into +// more than one client's program. Skew is then denied by default, with a small +// allowlist of packages verified to tolerate it (below). A dependency that +// starts skewing therefore fails `validate` and forces a decision, rather than +// surfacing months later as an unexplained OOM. + +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { rootReachesScript } from "./lib/npm-scripts.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +// The first-party source trees that are compiled into more than one install's +// `tsc` program, and so define which dependencies can appear twice in one +// program. `core/` is consumed by every client via the `@inspector/core` alias; +// `test-servers/src` is pulled into the web and cli test projects. +const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; + +// Packages whose cross-install skew is verified benign, each with the reason. +// This is an allowlist of *names*, not of version pairs, so an ordinary patch +// float within one of these does not churn the file — while any package NOT +// listed here failing the check is a genuine, unreviewed new skew. +// +// The admission test is the one the zod incident established: does the +// package's public type surface consist of deeply recursive generics that +// first-party code relates across the boundary? If yes it must stay in +// lockstep; if no, a patch-level difference costs nothing. +const TOLERATED_SKEW = new Map([ + [ + "react", + "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", + ], + [ + "hono", + "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", + ], + [ + "jose", + "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", + ], + [ + "@modelcontextprotocol/ext-apps", + "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", + ], +]); + +/** Package names that are Node built-ins (with or without the `node:` prefix). */ +const BUILTINS = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), +]); + +// A bare package specifier: optional `@scope/`, then a name, then any subpath. +// Anchored so prose that happens to sit after the word `from` in a comment +// ("from cwd omitted") cannot be mistaken for an import. +const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; + +/** + * The bare package name a module specifier resolves to — `@scope/name` or + * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for + * relative paths, built-ins, URLs, and anything not shaped like a specifier. + */ +export function packageNameOf(specifier) { + if (typeof specifier !== "string" || specifier === "") return null; + if (specifier.startsWith(".") || specifier.startsWith("/")) return null; + if (BUILTINS.has(specifier)) return null; + const m = PACKAGE_SPECIFIER.exec(specifier); + if (!m) return null; + const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; + // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. + if (name.includes(":")) return null; + return name; +} + +// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic +// `import("x")`. Only these three forms introduce a dependency's *types*. +const SPECIFIER_FORMS = [ + /\bfrom\s*["']([^"']+)["']/g, + /\bimport\s+["']([^"']+)["']/g, + /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, +]; + +/** + * Every third-party package name imported by a blob of TypeScript source. + * Deliberately a regex scan rather than a parse: it only needs to over- rather + * than under-approximate, since a name absent from every lockfile contributes + * nothing downstream (`@inspector/core` is a build-time alias, not a package, + * and drops out that way). + */ +export function importedPackageNames(source) { + const names = new Set(); + for (const re of SPECIFIER_FORMS) { + for (const m of source.matchAll(re)) { + const name = packageNameOf(m[1]); + if (name) names.add(name); + } + } + return names; +} + +/** + * Top-level installed versions of every package in a parsed lockfile, keyed by + * package name. Only `node_modules/<pkg>` entries count — a *nested* + * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside + * one install, which is routine and not what this guard is about. + */ +export function topLevelLockVersions(lock) { + const versions = new Map(); + for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { + const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); + if (!m || typeof entry?.version !== "string") continue; + versions.set(m[1], entry.version); + } + return versions; +} + +/** + * Find candidate packages that resolve to more than one version across the + * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per + * skewed package, sorted by name, each listing the version each install holds. + * Packages present in fewer than two installs cannot skew and are skipped. + */ +export function findSkew(candidates, installs) { + const skewed = []; + for (const name of [...candidates].sort()) { + const holders = installs + .filter(({ versions }) => versions.has(name)) + .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); + if (holders.length < 2) continue; + const distinct = new Set(holders.map((h) => h.version)); + if (distinct.size > 1) skewed.push({ name, holders }); + } + return skewed; +} + +/** Split skewed packages into the tolerated ones and the failures. */ +export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + return { + failures: skewed.filter((s) => !tolerated.has(s.name)), + ignored: skewed.filter((s) => tolerated.has(s.name)), + }; +} + +/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +function sharedSourceFiles() { + const out = execFileSync( + "git", + ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + { cwd: repoRoot, encoding: "utf8" }, + ); + return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); +} + +/** + * The installs to compare: the repo root plus every `clients/*` that carries a + * lockfile. Discovered from disk rather than listed, so a new client is covered + * without editing this guard (the same enrollment style as + * `verify:typecheck-coverage`). + */ +function installDirs() { + const clientsDir = path.join(repoRoot, "clients"); + const clients = existsSync(clientsDir) + ? readdirSync(clientsDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .sort() + : []; + return ["."] + .concat(clients) + .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); +} + +/** + * Run the guard. Prints its verdict and `process.exit(1)`s on any failure. + * Called only when this file is executed directly — importing it (for tests) + * gives access to the pure helpers above without running any of this. + */ +export function main() { + const rootScripts = JSON.parse( + readFileSync(path.join(repoRoot, "package.json"), "utf8"), + ).scripts; + + // Vouch for a sibling guard: a guard cannot detect being unrun itself, but the + // three can vouch for one another, so dropping any single one from `validate` + // is caught by another. `verify:format-coverage` vouches for this one in turn. + if (!rootReachesScript(rootScripts, "verify:format-coverage")) { + console.error( + "verify:dep-lockstep — the root `validate` no longer runs `verify:format-coverage` (a sibling guard). Restore it.", + ); + process.exit(1); + } + + const files = sharedSourceFiles(); + if (files.length === 0) { + console.error( + `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + ); + process.exit(1); + } + + const candidates = new Set(); + for (const file of files) { + const source = readFileSync(path.join(repoRoot, file), "utf8"); + for (const name of importedPackageNames(source)) candidates.add(name); + } + + const dirs = installDirs(); + const installs = dirs.map((dir) => ({ + dir, + versions: topLevelLockVersions( + JSON.parse( + readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), + ), + ), + })); + + const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + + if (failures.length > 0) { + console.error( + `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, + ); + for (const { name, holders } of failures) { + console.error(` ${name}`); + for (const { dir, version } of holders) + console.error(` ${version} (${dir})`); + } + console.error( + "\nThese packages' types are compiled into a single `tsc` program from two installs" + + `\n(${SHARED_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", + ); + console.error( + "\nAlign them — `npm install <pkg>@<version>` in each install so all lockfiles agree — or, if this" + + "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + + "\nwith the reason. See AGENTS.md.", + ); + process.exit(1); + } + + const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; + console.log( + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + ); +} + +// Run only when executed directly (`node scripts/verify-dep-lockstep.mjs`); +// importing this file (tests) exposes the pure helpers without running the guard. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs new file mode 100644 index 00000000..6e36d255 --- /dev/null +++ b/scripts/verify-dep-lockstep.test.mjs @@ -0,0 +1,196 @@ +// Table-driven tests for the pure helpers of the dep-lockstep guard (#1896). +// One case per rule the guard encodes — the comment names the rule, so a future +// change that relaxes one is visible as a deleted assertion rather than a quiet +// behavior shift. Run via `npm run test:scripts` (node:test; the root has no +// vitest harness). + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + findSkew, + importedPackageNames, + packageNameOf, + partitionSkew, + topLevelLockVersions, +} from "./verify-dep-lockstep.mjs"; + +test("packageNameOf: bare names, scopes, and subpaths", () => { + const cases = [ + ["zod", "zod"], + ["zod/v4", "zod"], // subpath dropped — one package, one version + ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], + ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], + ["react-dom/client", "react-dom"], + ]; + for (const [input, expected] of cases) + assert.equal(packageNameOf(input), expected, input); +}); + +test("packageNameOf: non-packages are rejected", () => { + // Relative/absolute paths, built-ins with and without the `node:` prefix, + // protocol specifiers, and prose that follows the word `from` in a comment. + const rejected = [ + "./foo", + "../core/mcp", + "/abs/path", + "fs", + "path", + "node:crypto", + "node:test", + "file:", + "data:text/plain,x", + "cwd omitted", + "", + ]; + for (const input of rejected) + assert.equal(packageNameOf(input), null, JSON.stringify(input)); + assert.equal(packageNameOf(undefined), null); +}); + +test("importedPackageNames: the three specifier forms that introduce types", () => { + const source = ` + import { z } from "zod/v4"; + export type { Foo } from '@modelcontextprotocol/core'; + import "./side-effect.css"; + import "pino"; + const mod = await import("chokidar"); + import fs from "node:fs"; + import { helper } from "../local/helper"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@modelcontextprotocol/core", + "chokidar", + "pino", + "zod", + ]); +}); + +test("importedPackageNames: prose after `from` is not an import", () => { + // The scan over-approximates on purpose, but must not invent package names + // out of comment text — a bogus name absent from every lockfile is inert, + // yet a *plausible* one would silently widen the candidate set. + const source = ` + // Resolved relative to the runner dir, not from "cwd omitted" by the caller. + /** Reads the manifest from "file:" URLs. */ + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + +test("topLevelLockVersions: nested duplicates are ignored", () => { + // A nested `node_modules/a/node_modules/b` is npm resolving a transitive + // conflict *inside* one install — routine, and not the cross-install skew + // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 + // is the live example). + const lock = { + packages: { + "": { name: "root" }, + "node_modules/zod": { version: "4.4.3" }, + "node_modules/yaml": { version: "2.9.0" }, + "node_modules/cosmiconfig/node_modules/yaml": { version: "1.10.3" }, + "node_modules/@modelcontextprotocol/client": { version: "2.0.0-beta.5" }, + "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, + }, + }; + assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ + ["@modelcontextprotocol/client", "2.0.0-beta.5"], + ["yaml", "2.9.0"], + ["zod", "4.4.3"], + ]); +}); + +test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + for (const lock of [undefined, null, {}, { packages: {} }]) + assert.equal(topLevelLockVersions(lock).size, 0); +}); + +test("findSkew: reports a package held at two versions", () => { + const installs = [ + { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["zod"]), installs), [ + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + { dir: "clients/cli", version: "4.4.3" }, + ], + }, + ]); +}); + +test("findSkew: agreement and single-install packages are not skew", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "4.4.3"], + ["express", "5.2.1"], + ]), + }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + // `express` lives in one install only, so it cannot skew — a package absent + // from a client is not a finding. + assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); +}); + +test("findSkew: a candidate in no lockfile is inert", () => { + // `@inspector/core` is a build-time alias, not a package; the scan picks it + // up and it must drop out here rather than error. + const installs = [ + { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, + { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, + ]; + assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); +}); + +test("findSkew: results are sorted by package name", () => { + const installs = [ + { + dir: ".", + versions: new Map([ + ["zod", "1.0.0"], + ["hono", "1.0.0"], + ]), + }, + { + dir: "clients/web", + versions: new Map([ + ["zod", "2.0.0"], + ["hono", "2.0.0"], + ]), + }, + ]; + assert.deepEqual( + findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), + ["hono", "zod"], + ); +}); + +test("partitionSkew: the allowlist is by name, not by version pair", () => { + // So an ordinary patch float within a tolerated package does not churn the + // allowlist, while any *unlisted* package that starts skewing still fails. + const skewed = [ + { name: "react", holders: [] }, + { name: "zod", holders: [] }, + ]; + const tolerated = new Map([["react", "shallow interfaces"]]); + const { failures, ignored } = partitionSkew(skewed, tolerated); + assert.deepEqual( + failures.map((s) => s.name), + ["zod"], + ); + assert.deepEqual( + ignored.map((s) => s.name), + ["react"], + ); +}); + +test("partitionSkew: deny by default — nothing tolerated fails everything", () => { + const skewed = [{ name: "zod", holders: [] }]; + assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 8963c8c2..014b850f 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,15 +166,16 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guard: a guard can't detect being unrun itself, but the -// two coverage guards can each assert the other is still wired into `validate`, -// so dropping either is caught here. Only deleting both slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, but the +// three can each assert the others are still wired into `validate`, so dropping +// any one is caught here. Only deleting all of them slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; -if (!rootReachesScript(rootScripts, "verify:typecheck-coverage")) { +for (const sibling of ["verify:typecheck-coverage", "verify:dep-lockstep"]) { + if (rootReachesScript(rootScripts, sibling)) continue; console.error( - "verify:format-coverage — the root `validate` no longer runs `verify:typecheck-coverage` (its sibling guard). Restore it.", + `verify:format-coverage — the root \`validate\` no longer runs \`${sibling}\` (its sibling guard). Restore it.`, ); process.exit(1); } From e328da43b5cc9fb4d9e9c4a1700a5ecfdf9e41d8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:34:14 -0400 Subject: [PATCH 046/941] test: cover the duplicateToolNames server option over a live connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ToolControls regressions build their `Tool[]` by hand, so they would still pass if the new server option broke — config loading, resolveConfig, the ordering, the title marker, or its composition with pagination. Copilot flagged exactly that gap. Adds an integration test that connects a real InspectorClient to a real streamable-HTTP test server and asserts the wire shape: repeats appended (not adjacent — the ordering the defect depends on), only the appended copy marked "(duplicate)", an unregistered name ignored, an empty list a no-op, and duplication running before the page slice so a pair straddles a boundary. The last case loads `duplicate-tool-names-http.json` through loadConfig + resolveConfig, so the JSON plumbing the manual repro uses is covered too. Verified non-vacuous: mutating withDuplicates to place repeats adjacent fails four of the five. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../mcp/duplicate-tool-names.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts diff --git a/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts b/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts new file mode 100644 index 00000000..86b2bd9d --- /dev/null +++ b/clients/web/src/test/integration/mcp/duplicate-tool-names.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, + createGetWeatherTool, + loadConfig, + resolveConfig, +} from "@modelcontextprotocol/inspector-test-server"; +import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of `ServerConfig.duplicateToolNames` (#1957) — the only way this + * repo can serve a `tools/list` that repeats a name, since every preset + * registers a unique one and the SDK's `registerTool` rejects a repeat. + * + * The Tools sidebar keyed its rows by `tool.name`, so duplicates collided and + * filtering left an unrelated row mounted. The component-level regressions live + * in `ToolControls.test.tsx`; this file covers the server option those + * screenshots and the manual repro depend on — the wire shape, the ordering + * that makes the defect observable, and the config plumbing. + */ +describe("duplicate tool names in tools/list (#1957)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + async function start(config: Partial<ServerConfig>): Promise<TestServerHttp> { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("duplicate-tool-names-test", "1.0.0"), + tools: [createEchoTool(), createGetWeatherTool()], + ...config, + }); + await started.start(); + server = started; + return started; + } + + async function connect(url: string): Promise<InspectorClient> { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { environment: { transport: createTransportNode } }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("emits the named tools twice, repeats appended, second copy titled", async () => { + const started = await start({ duplicateToolNames: ["echo"] }); + const connected = await connect(started.url); + + const { tools } = await connected.listAllTools(); + + // Repeats go at the END, not beside their twin. That ordering is + // load-bearing: React matches a leading run of same-key children first, so + // an adjacent duplicate lines up and the defect hides. Asserting the exact + // sequence keeps a future "tidy-up" from silently defanging the fixture. + expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather", "echo"]); + // These presets carry no title, so the marker falls back to the name — + // which is what keeps the two rows distinguishable on screen. + expect(tools.at(-1)?.title).toBe("echo (duplicate)"); + // Only the appended copy is marked; the originals are passed through as-is. + expect(tools[0]?.title).toBeUndefined(); + expect(tools[1]?.title).toBeUndefined(); + }); + + it("leaves the list alone when no names are given", async () => { + const started = await start({ duplicateToolNames: [] }); + const connected = await connect(started.url); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]); + }); + + it("ignores a name that is not registered", async () => { + const started = await start({ duplicateToolNames: ["not_a_tool"] }); + const connected = await connect(started.url); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["echo", "get_weather"]); + }); + + it("duplicates before paginating, so a pair straddles a page boundary", async () => { + const started = await start({ + duplicateToolNames: ["echo", "get_weather"], + maxPageSize: { tools: 2 }, + }); + const connected = await connect(started.url); + + // Four tools at a page size of two: the duplicated copies land on page 2, + // which only holds if duplication runs before the slice. + const firstPage = await connected.listTools(); + expect(firstPage.tools.map((t) => t.name)).toEqual(["echo", "get_weather"]); + expect(firstPage.nextCursor).toBeDefined(); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual([ + "echo", + "get_weather", + "echo", + "get_weather", + ]); + expect(tools.slice(2).map((t) => t.title)).toEqual([ + "echo (duplicate)", + "get_weather (duplicate)", + ]); + }); + + it("serves the shape the showcase config declares", async () => { + // Covers the JSON → ConfigFile → ServerConfig plumbing, not just the + // in-process option: a config file is how the manual repro and the + // screenshots in #1957 are produced. + const configPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../../test-servers/configs/duplicate-tool-names-http.json", + ); + const resolved = resolveConfig(loadConfig(configPath)); + expect(resolved.duplicateToolNames).toEqual(["get_weather", "echo"]); + + // Let the harness pick the port instead of the config's fixed one, so this + // test can't collide with a manually-running showcase server. + const started = await start({ + tools: resolved.tools, + duplicateToolNames: resolved.duplicateToolNames, + }); + const connected = await connect(started.url); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual([ + "get_weather", + "get_temp", + "echo", + "add", + "get_weather", + "echo", + ]); + + // The whole point of the fixture: filtering by "get" must be able to drop + // every non-matching row, duplicates included. + const matching = tools.filter( + (t) => + t.name.includes("get") || + (t.title?.toLowerCase().includes("get") ?? false), + ); + expect(matching).toHaveLength(3); + }); +}); From e480fdc49d5923e17a9b85407a660d6599990c8b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:38:41 -0400 Subject: [PATCH 047/941] chore(deps): cover .mts/.cts in the shared-source scan, fix the README validate row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from Copilot's review of #1962. The candidate-set enumeration matched only `.ts`/`.tsx`, while `verify:format-coverage` and `verify:typecheck-coverage` both gate all four TypeScript extensions. No `.mts`/`.cts` exists under `core/` or `test-servers/src` today, which is exactly why the omission would have gone unnoticed until a shared dependency arrived through one and skewed — the failure this guard exists to prevent. Extract the rule as the exported `isSharedSourceFile` predicate, cover all four extensions, and pin it with regression tests (including path-boundary anchoring, so a `core-internal/` sibling isn't swept in). The README's `validate` row also omitted `test:scripts` and claimed typecheck was cli/tui only; launcher runs one too, and web typechecks via `tsc -b` inside its `build`. Restate the row to match the actual script chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 20 +++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 32 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 635d93cf..fef78256 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs `verify:format-coverage` (asserts every tracked source file is format-gated) first, then `verify:typecheck-coverage` and `verify:dep-lockstep`, then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui only) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no install-crossing dependency skews) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 496fdc8d..487a927c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -170,14 +170,30 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { }; } -/** Tracked `.ts`/`.tsx` files under the shared first-party source trees. */ +// The TypeScript extensions the shared trees can hold. Deliberately the same +// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a +// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any +// other source, so its imports must reach the candidate set too. None exist +// under those trees today, which is exactly why omitting them would go +// unnoticed until a new shared dependency arrived through one and skewed. +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; + +/** Whether a repo-relative path is a TypeScript source of a shared tree. */ +export function isSharedSourceFile(file) { + if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; + // Anchored on a path boundary so a sibling whose name merely starts with a + // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. + return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); +} + +/** Tracked TypeScript files under the shared first-party source trees. */ function sharedSourceFiles() { const out = execFileSync( "git", ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], { cwd: repoRoot, encoding: "utf8" }, ); - return out.split("\n").filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); + return out.split("\n").filter(isSharedSourceFile); } /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6e36d255..640291fb 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -9,11 +9,43 @@ import assert from "node:assert/strict"; import { findSkew, importedPackageNames, + isSharedSourceFile, packageNameOf, partitionSkew, topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; +test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { + // `.mts`/`.cts` are gated by `verify:format-coverage` and + // `verify:typecheck-coverage` too. None exist under the shared trees today, + // so dropping them here would go unnoticed until a new shared dependency + // arrived through one — the skew this guard exists to catch (Copilot, #1962). + for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { + assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext); + assert.equal( + isSharedSourceFile(`test-servers/src/thing${ext}`), + true, + `test-servers ${ext}`, + ); + } +}); + +test("isSharedSourceFile: non-TS files and other trees are excluded", () => { + const rejected = [ + "core/README.md", // not TypeScript + "core/mcp/data.json", + "clients/web/src/App.tsx", // a client's own sources resolve from the client + "scripts/verify-dep-lockstep.mjs", + "test-servers/configs/modern-http.json", + // Path-boundary anchoring: a sibling dir whose name merely starts with a + // shared dir's name must not be swept in. + "core-internal/thing.ts", + "test-servers/src-legacy/thing.ts", + ]; + for (const file of rejected) + assert.equal(isSharedSourceFile(file), false, file); +}); + test("packageNameOf: bare names, scopes, and subpaths", () => { const cases = [ ["zod", "zod"], From 20527dc1776a1f75d09d7670ff84f02c1e63c974 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:40:39 -0400 Subject: [PATCH 048/941] =?UTF-8?q?docs:=20address=20Copilot=20review=20on?= =?UTF-8?q?=20the=20v1=20=E2=86=92=20v2=20migration=20guide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sandbox port: the Apps tab needs a second, dynamic listener, so "no second port" was wrong for Docker/remote deployments. Qualify the architecture section, add a pinned-port Docker recipe, and add a troubleshooting entry. - Node floor: npm only warns EBADENGINE unless engine-strict is set, so npx does not refuse to run on an older Node. - v1 default list: the web UI persisted its list in localStorage; only the on-disk default catalog is new. - The v1→v2 --server example needs --cli (and a --method) to select an entry; note there is no one-entry web equivalent. - New behavior change #4: an ambiguous URL path (neither /mcp nor /sse) now errors instead of falling back to SSE. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 51 ++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index ef2865fc..745779c9 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -30,7 +30,7 @@ The three changes most likely to bite an existing setup: ## Requirements -v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. `npx` will refuse to run on an older Node rather than fail obscurely later. +v2 requires **Node `>=22.19.0`** (v1 required `>=22.7.5`). The floor comes from `undici@^8`, used for HTTP proxy support. npm only _warns_ about an `engines` mismatch (`EBADENGINE`) unless you have `engine-strict=true` set, so an older Node won't stop you at install time — it fails later, obscurely. Check `node -v` first. ## What no longer ships @@ -58,13 +58,15 @@ If you depend on one of them directly, drop it and use the root package's `mcp-i v1 ran **two** processes: a React client on `6274` and an "MCP Proxy" on `6277` that held the actual MCP connections. The browser talked to the proxy over HTTP, authenticating with `MCP_PROXY_AUTH_TOKEN`. -v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. There is no second port to expose, forward, or allow through a firewall. +v2 runs **one** web server on `6274`. It serves the SPA and exposes `/api/*`, which the browser calls with `x-mcp-remote-auth: Bearer <MCP_INSPECTOR_API_TOKEN>`. The proxy port is gone: nothing needs `6277` exposed, forwarded, or allowed through a firewall. Consequences: - `SERVER_PORT` no longer selects a port you browse. (It is read as a fallback for the MCP Apps sandbox server's port — see `MCP_SANDBOX_PORT` below.) - `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. -- Docker only needs `-p 6274:6274`. The v1 recipe published `6277` as well. +- Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. + +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). ## Launching @@ -102,7 +104,7 @@ So: - **`--config` in v2 is the read-only role.** Point it at a file you didn't write — a coworker's, a client application's, one checked into a repo — and the Inspector guarantees it won't touch the bytes, including any plaintext secrets in them. This is close to v1's behavior, minus the write-back that never existed anyway. - **`--catalog` is new** and is what you want if you'd like the web UI to add, edit, and remove servers. -- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no such default — it started empty every time. +- **Neither is required.** With no source flag, v2 uses the default catalog `~/.mcp-inspector/mcp.json`, creating it if absent. v1 had no default file: the CLI started with nothing unless you passed `--config`, and the web UI's list lived in the browser's `localStorage` — per-browser, and invisible to the CLI. ### Before / after @@ -112,11 +114,11 @@ So: # v1 — one entry out of a file npx @modelcontextprotocol/inspector --config ./mcp.json --server everything -# v2 — same thing, same read-only guarantee -npx @modelcontextprotocol/inspector --config ./mcp.json --server everything +# v2 — same file, same read-only guarantee; --server selects under --cli +npx @modelcontextprotocol/inspector --cli --config ./mcp.json --server everything --method tools/list ``` -⚠️ One caveat on that last line: **`--server` only selects under `--cli`.** On the web client it is a no-op that logs a warning, and the TUI rejects it as an unknown option. In the web UI you pick the server from the list after it loads. +⚠️ **`--server` only selects under `--cli`**, which is why the v2 line above gains the mode flag (and, being a CLI invocation, a `--method`). On the web client `--server` is a no-op that logs a warning and the UI loads **every** entry in the file; the TUI rejects it as an unknown option. There is no web equivalent of "open just this one entry" — you pick the server from the list after it loads. ```bash # v2 — let the Inspector manage the file (web UI can edit it) @@ -153,7 +155,7 @@ Every v1 CLI flag still exists in v2 and means the same thing. Nothing was renam | `-e KEY=VALUE` | same | | | `--config <path>` | same flag, **narrower meaning** | see [above](#--config-vs---catalog) | | `--server <name>` | same | now `--cli`-only | -| `[target...]` | same | but see the target-ordering rule below | +| `[target...]` | same | but see the target-ordering **and** URL-transport rules below | New in v2, with no v1 equivalent: @@ -175,7 +177,7 @@ See the [CLI README](../clients/cli/README.md) for the full surface. ## CLI behavior changes -The flags survived; three behaviors did not. +The flags survived; four behaviors did not. ### 1. Exit codes and the error envelope @@ -234,6 +236,24 @@ mcp-inspector --cli node build/index.js -- --method tools/list So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. +### 4. An ambiguous URL path no longer guesses a transport + +With no `--transport`, v2 infers it from the URL's path suffix — and **only** from that: + +| URL path | v2 | +| -------------- | ---------------------------------- | +| ends in `/mcp` | `streamable-http` | +| ends in `/sse` | `sse` | +| anything else | **error — `--transport` required** | + +``` +Transport type not specified and could not be determined from URL: <url>. +``` + +v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://example.com/api` connected without a flag. In v2 the same command stops with the error above; pass `--transport http` (or `sse`) explicitly. The suffix match is exact, so a trailing slash (`…/mcp/`) is ambiguous too. + +This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. + Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. ## Environment variables @@ -293,12 +313,19 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ -e HOST=0.0.0.0 -e MCP_AUTO_OPEN_ENABLED=false \ ghcr.io/modelcontextprotocol/inspector:1.0.1 -# v2 — one port; the image already sets the wildcard-bind opt-in +# v2 — no proxy port; the image already sets the wildcard-bind opt-in docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: +- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: + + ```bash + docker run --rm -p 6274:6274 -p 6280:6280 -e MCP_SANDBOX_PORT=6280 \ + ghcr.io/modelcontextprotocol/inspector:latest + ``` + - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. @@ -314,6 +341,10 @@ Notes: **"The Inspector connects to a different server than I named."** Check flag order — under `--cli` the target must precede all flags, or it is silently dropped in favor of the catalog. +**"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. + +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). **"I need v1 back."** `npx @modelcontextprotocol/inspector@v1-latest`. From 7a74b5eba192f8fe2dbfdbc491bc61580041ceb8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:44:37 -0400 Subject: [PATCH 049/941] fix: forward the negotiated protocol version to the remote backend transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web client's SDK Client owns the `initialize` handshake, so the `setProtocolVersion()` call it makes after initialize lands on RemoteClientTransport — a pass-through to the Node backend, not the real HTTP transport. The backend's StreamableHTTPClientTransport therefore never learned the negotiated version and stamped no `Mcp-Protocol-Version` on any request after initialize, starting with `notifications/initialized`. A stateful server that requires the header rejects that notification with HTTP 400. RemoteClientTransport now records the version and rides it on every `/api/mcp/send`; the backend applies it to its transport before the send, so that request and everything the transport issues afterwards (the standalone SSE GET, the session DELETE) carry the header. The version is validated against a token pattern on the backend so a client can't push arbitrary bytes into an upstream header. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 57 +++++++++++++++++++ .../mcp/remote/remote-session.test.ts | 34 +++++++++++ .../integration/mcp/remote/transport.test.ts | 52 +++++++++++++++++ core/mcp/remote/node/remote-session.ts | 26 +++++++++ core/mcp/remote/node/server.ts | 8 ++- core/mcp/remote/remoteClientTransport.ts | 22 +++++++ core/mcp/remote/types.ts | 6 ++ 7 files changed, 204 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index 64a1328d..3c51abcb 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -515,4 +515,61 @@ describe("RemoteClientTransport", () => { reason: "token_expired", }); }); + + // #1935: the SDK Client calls setProtocolVersion() on this transport, but the + // real HTTP transport lives on the backend — so the version has to ride the + // send envelope or the backend drops `Mcp-Protocol-Version` upstream. + it("forwards the negotiated protocol version on every send after setProtocolVersion", async () => { + const seenBodies: string[] = []; + let sse = createPushableSseStream(); + const fetchFn = vi + .fn<typeof fetch>() + .mockImplementation(async (input, init) => { + const url = String(input); + if (url.endsWith("/api/mcp/connect")) { + return new Response(JSON.stringify({ sessionId: "abc" }), { + status: 200, + }); + } + if (url.includes("/api/mcp/events")) { + sse = createPushableSseStream(); + return sse.response; + } + if (url.endsWith("/api/mcp/send")) { + seenBodies.push(String(init?.body)); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const transport = new RemoteClientTransport( + { baseUrl, fetchFn: fetchFn as unknown as typeof fetch }, + config, + ); + await transport.start(); + + // Pre-initialize sends carry no version (none negotiated yet). + await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" }); + expect(JSON.parse(seenBodies[0]!)).not.toHaveProperty("protocolVersion"); + + transport.setProtocolVersion("2025-11-25"); + + // The first post-initialize send is `notifications/initialized` — the one + // the reporting server rejected for a missing header. + await transport.send({ + jsonrpc: "2.0", + method: "notifications/initialized", + }); + await transport.send({ jsonrpc: "2.0", method: "notifications/cancelled" }); + + expect(JSON.parse(seenBodies[1]!)).toMatchObject({ + protocolVersion: "2025-11-25", + message: { method: "notifications/initialized" }, + }); + expect(JSON.parse(seenBodies[2]!)).toMatchObject({ + protocolVersion: "2025-11-25", + }); + + await transport.close(); + }); }); diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index 5fe6f4de..a74e980c 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -120,6 +120,40 @@ describe("RemoteSession", () => { expect(session.transport).toBe(transport); }); + // #1935: the browser's Client negotiates the version, so the backend has to + // be told before it can stamp `Mcp-Protocol-Version` on upstream requests. + it("applyProtocolVersion forwards a new version to the transport once", () => { + const session = new RemoteSession("s8a"); + const setProtocolVersion = vi.fn(); + session.setTransport({ setProtocolVersion } as unknown as Transport); + + session.applyProtocolVersion("2025-11-25"); + session.applyProtocolVersion("2025-11-25"); + expect(setProtocolVersion.mock.calls).toEqual([["2025-11-25"]]); + + // A renegotiated version (e.g. after a reconnect) is re-applied. + session.applyProtocolVersion("2026-07-28"); + expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28"); + }); + + it("applyProtocolVersion ignores an absent or non-token version", () => { + const session = new RemoteSession("s8b"); + const setProtocolVersion = vi.fn(); + session.setTransport({ setProtocolVersion } as unknown as Transport); + + session.applyProtocolVersion(undefined); + // Header injection attempt — must never reach the upstream transport. + session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1"); + session.applyProtocolVersion(""); + expect(setProtocolVersion).not.toHaveBeenCalled(); + }); + + it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => { + const session = new RemoteSession("s8c"); + session.setTransport({} as unknown as Transport); + expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow(); + }); + it("hasEventConsumer reflects whether a consumer is attached", () => { const session = new RemoteSession("s9"); expect(session.hasEventConsumer()).toBe(false); diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index d66ae36a..2e2b55ec 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -452,6 +452,58 @@ describe("Remote transport e2e", () => { } }); + it("end-to-end: the negotiated Mcp-Protocol-Version reaches the upstream server after initialize (#1935)", async () => { + // The browser's SDK Client owns the initialize handshake, so its + // setProtocolVersion() lands on the *remote* transport. Without the + // forward to the backend, the backend's real streamable-HTTP transport + // never learns the version and every post-initialize request — starting + // with `notifications/initialized` — goes out without the header, which + // a stateful server may reject outright. + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createEchoTool()], + serverType: "streamable-http", + }); + await mcpHttpServer.start(); + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + + const { client, fetchRequestLogState } = + await setupRemoteAndConnect(config); + + try { + await client.listTools(); + + const protocolVersion = client.getProtocolVersion(); + expect(protocolVersion).toBeDefined(); + + const posts = fetchRequestLogState + .getFetchRequests() + .filter((r) => r.method === "POST"); + const headerFor = (body: string): string | undefined => { + const entry = posts.find((r) => (r.requestBody ?? "").includes(body)); + expect(entry).toBeDefined(); + for (const [k, v] of Object.entries(entry?.requestHeaders ?? {})) { + if (k.toLowerCase() === "mcp-protocol-version") return v; + } + return undefined; + }; + + // The initialize POST itself predates negotiation and carries none. + expect(headerFor('"method":"initialize"')).toBeUndefined(); + // Everything after it does. + expect(headerFor('"method":"notifications/initialized"')).toBe( + protocolVersion, + ); + expect(headerFor('"method":"tools/list"')).toBe(protocolVersion); + } finally { + await client.disconnect(); + } + }); + it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => { // A modern (2026-07-28) server whose `get_weather` tool annotates `city` // with `x-mcp-header: "City"`. The SDK's modern handler validates the diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 34a2db09..60ee176f 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -37,6 +37,9 @@ export class RemoteSession { static readonly AUTH_HTTP_ECHO_SUPPRESS_MS = 30_000; private readonly requestWaits = new Map<string | number, RequestWait>(); private authProviderHandle: RemoteAuthProviderHandle | null = null; + private appliedProtocolVersion: string | undefined; + /** A protocol version is a dated token (`2025-11-25`, `2026-07-28`, …). */ + private static readonly PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9._-]{1,64}$/; constructor(sessionId: string) { this.sessionId = sessionId; @@ -57,6 +60,29 @@ export class RemoteSession { this.transport = transport; } + /** + * Apply the client's negotiated protocol version to the upstream transport + * (#1935). The browser's SDK Client owns the `initialize` handshake, so the + * `setProtocolVersion` call it makes lands on the *remote* transport; without + * this the backend's real HTTP transport never learns the version and drops + * `Mcp-Protocol-Version` from every subsequent request. + * + * Ignores anything that isn't a plausible version token so a client can't + * push arbitrary bytes into an upstream header, and re-applies only on + * change (the value rides every `/api/mcp/send`). + */ + applyProtocolVersion(version: string | undefined): void { + if ( + version === undefined || + version === this.appliedProtocolVersion || + !RemoteSession.PROTOCOL_VERSION_PATTERN.test(version) + ) { + return; + } + this.appliedProtocolVersion = version; + this.transport.setProtocolVersion?.(version); + } + setEventConsumer(consumer: (event: SessionEvent) => void): void { this.eventConsumer = consumer; // Flush queued events diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4bbc518a..8a3f2a05 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -728,7 +728,8 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { sessionId, message, relatedRequestId, headers } = body; + const { sessionId, message, relatedRequestId, headers, protocolVersion } = + body; if (!sessionId || !message) { return c.json({ error: "Missing sessionId or message" }, 400); } @@ -744,6 +745,11 @@ export function createRemoteApp( return c.json({ ok: false, kind: "transport_error", error: errorMsg }); } + // The browser's SDK Client negotiated the version; hand it to the real + // upstream transport before the send so `Mcp-Protocol-Version` is stamped + // on this request and everything after it (#1935). + session.applyProtocolVersion(protocolVersion); + session.beginSend(); const requestId = requestIdForSendWait(message); let responseWait: Promise<void> | undefined; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 03c92461..a9dfed78 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -221,6 +221,7 @@ async function* parseSSE( */ export class RemoteClientTransport implements Transport { private _sessionId: string | undefined = undefined; + private _protocolVersion: string | undefined = undefined; private eventStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null; private eventStreamAbort: AbortController | null = null; @@ -250,6 +251,22 @@ export class RemoteClientTransport implements Transport { return this._sessionId; } + /** + * The SDK Client calls this with the negotiated protocol version as soon as + * `initialize` resolves — before it sends `notifications/initialized` — so an + * HTTP transport can stamp `Mcp-Protocol-Version` on every later request. + * + * Here the real upstream transport lives on the backend, so we can only + * record the version and forward it on the next `/api/mcp/send`, which + * applies it to the upstream transport *before* sending. Because + * `notifications/initialized` is that next send, it and everything after it + * (including the standalone SSE GET and the session DELETE, both issued by + * the upstream transport itself) carry the header (#1935). + */ + setProtocolVersion(version: string): void { + this._protocolVersion = version; + } + /** * Reattach to an existing remote backend session after a full-page OAuth * redirect. Opens the SSE event stream without POST /connect. @@ -707,6 +724,11 @@ export class RemoteClientTransport implements Transport { // Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to // apply to the upstream send; the browser can't set them cross-origin. ...(options?.headers != null && { headers: options.headers }), + // Forward the negotiated protocol version so the backend's transport can + // stamp `Mcp-Protocol-Version` on this and every later request (#1935). + ...(this._protocolVersion !== undefined && { + protocolVersion: this._protocolVersion, + }), }; const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 23bb6aa9..9e948401 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -125,6 +125,12 @@ export interface RemoteSendRequest { * them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix. */ headers?: Record<string, string>; + /** + * MCP protocol version negotiated by the client's `initialize`. The backend + * applies it to the upstream transport before the send so HTTP transports + * stamp `Mcp-Protocol-Version` on this and every later request (#1935). + */ + protocolVersion?: string; } export type RemoteEventType = From 47e0ee059a0dc1a1134362c6a8369b104298772b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:47:09 -0400 Subject: [PATCH 050/941] chore(deps): match CommonJS and template-literal imports in the shared-source scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the suppressed comment on Copilot's second pass of #1962. Under- approximating is the dangerous direction here: a package the scan misses never enters the candidate set, so its skew passes the guard silently. Three forms went unmatched. `import x = require("pkg")` and a bare `require("pkg")` are the ordinary import syntax in `.cts` — which the previous commit just admitted to the scan, so leaving them out would have been an inconsistency introduced by that widening. A dynamic import carrying import attributes (`import("pkg", { with: … })`) was missed because the pattern required the closing paren. A static template literal (`import(\`pkg\`)`) was missed for want of a backtick in the quote class. Backticks are accepted only in the *call* forms. A `from` clause requires a string literal, so allowing them there would buy nothing while reopening the prose hazard the anchored specifier pattern closes: inline code in comments is written with backticks throughout this codebase, and admitting it inflated the derived set from 18 names to 41 with prose fragments (`tools`, `connect()`, `messages`). Inert today, since a name absent from every lockfile contributes nothing — but a prose word colliding with a real package name would silently widen the set. Pinned with a regression test. The real set is unchanged at 18, so these forms add no package today; they close the syntax against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 23 ++++++++++++++----- scripts/verify-dep-lockstep.test.mjs | 33 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 487a927c..0eb8a9dd 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -101,12 +101,25 @@ export function packageNameOf(specifier) { return name; } -// `from "x"` (import and re-export), a side-effect `import "x"`, and a dynamic -// `import("x")`. Only these three forms introduce a dependency's *types*. +// Every form that can introduce a dependency's *types* (Copilot, #1962). +// `import x = require("pkg")` and a bare `require("pkg")` matter because the +// shared trees may hold `.cts` sources, where that is the ordinary import +// syntax; and neither call form requires the closing paren, so an import +// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// +// Backticks are accepted ONLY in the call forms, where a static template +// literal is legal. A `from` clause requires a string literal, so allowing +// backticks there would buy nothing while reopening the prose hazard the +// anchored specifier pattern exists to close — inline code in a comment is +// written with backticks throughout this codebase, and "…derived from +// `tools`…" would otherwise enter the candidate set as a package named +// `tools`. Inert today, but a prose word that collides with a real package +// name would silently widen the set. const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, - /\bimport\s+["']([^"']+)["']/g, - /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g, + /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" + /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" + /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) + /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 640291fb..890d90b5 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -79,6 +79,26 @@ test("packageNameOf: non-packages are rejected", () => { assert.equal(packageNameOf(undefined), null); }); +test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => { + // Under-approximating is the dangerous direction: a package the scan misses + // never enters the candidate set, so its skew passes the guard silently. + // `.cts` sources in the shared trees use `import x = require(…)` as ordinary + // syntax, and a dynamic import may carry import attributes or a static + // template literal — none of which the original three patterns matched. + const source = ` + import express = require("express"); + const yaml = require("yaml"); + const a = await import("undici", { with: { type: "json" } }); + const b = await import(\`jose\`); + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "jose", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; @@ -109,6 +129,19 @@ test("importedPackageNames: prose after `from` is not an import", () => { assert.deepEqual([...importedPackageNames(source)], ["zod"]); }); +test("importedPackageNames: a backticked `from` in prose is not an import", () => { + // Backticks are legal in the *call* forms (a static template literal) but + // never after `from`, which requires a string literal. Accepting them there + // would sweep in inline code from comments — this codebase writes it with + // backticks throughout — and a prose word colliding with a real package name + // would silently widen the candidate set. + const source = ` + /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + import { z } from "zod"; + `; + assert.deepEqual([...importedPackageNames(source)], ["zod"]); +}); + test("topLevelLockVersions: nested duplicates are ignored", () => { // A nested `node_modules/a/node_modules/b` is npm resolving a transitive // conflict *inside* one install — routine, and not the cross-install skew From 908284d04655c865a8a90be8f1035460cb6e3495 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:47:40 -0400 Subject: [PATCH 051/941] docs: address Copilot's suppressed comments on the migration guide - `--` under --cli: everything before the separator IS forwarded to the target, flags included (cli.ts:536-541), so "no way to pass a leading-dash argument" was wrong. Show the correct placement instead of sending users to a catalog file. - Stdout: v2's emitResult appends "\n" where v1's awaitableLog wrote the JSON with no terminator, so the output is not byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 745779c9..04f2a640 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -234,7 +234,17 @@ Under **`--cli`** it is reversed: everything _before_ `--` is the target, everyt mcp-inspector --cli node build/index.js -- --method tools/list ``` -So the web example above, run under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line — put it in the server entry's `args` in a catalog or config file instead. +So the web example above, run verbatim under `--cli`, would have `--config /etc/myserver.conf` consumed as the Inspector's read-only-session flag (and then rejected as a catalog/ad-hoc conflict). To pass a leading-dash argument through to a stdio server, put it **before** the `--` instead — everything on that side is forwarded to the target untouched, flags included: + +```bash +# v1 +mcp-inspector node build/index.js -- --config /etc/myserver.conf --verbose + +# v2, same thing under --cli — target and its flags first, Inspector options after +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +(Without a `--` on the line, the target is only the leading run of non-dash tokens — so `--` is required whenever the server itself takes flags.) ### 4. An ambiguous URL path no longer guesses a transport @@ -254,7 +264,7 @@ v1 fell back to SSE for an unrecognized path, so a server at e.g. `https://examp This applies to every client's command line — CLI, TUI, and `--web` (which prints the message and exits `1`). The **browser deep link** is the one exception: `?serverUrl=…` with no `transport` param defaults to `http`. -Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`, exactly as v1 did. +Stdout is otherwise compatible: the default `text` format still pretty-prints the result as `JSON.stringify(result, null, 2)`. One byte differs — v2 appends a trailing newline where v1 wrote none — so a script diffing raw stdout against a stored v1 fixture needs the fixture re-captured (or the comparison trimmed). ## Environment variables From 8c9cf40211700474a26a922862742cbfc1490b35 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:53:45 -0400 Subject: [PATCH 052/941] docs: qualify the ad-hoc rule and fix the contradicting `--` guidance - The "nothing combines with an ad-hoc target" rule has a web exception: run-web.ts:130 exempts `--transport stdio`, so web ignores it rather than rejecting. Only cli/tui reject every `--transport`. - mcp-server-configuration.md still said a leading-dash argument cannot reach a stdio server under --cli, the opposite of what the migration guide now says. The parser puts every pre-`--` token in targetArgs, so the guide is right; corrected the older section to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/mcp-server-configuration.md | 8 +++++++- docs/v1-to-v2-migration.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 88250cd6..00455a24 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -99,7 +99,13 @@ They differ on **when you need it**, though. Web sets `allowUnknownOption()` + ` mcp-inspector --cli node build/index.js -- --method tools/list ``` -So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). There is currently no way to pass a leading-dash argument through to a stdio server on the `--cli` command line; put it in the server entry's `args` in a catalog or config file instead. +So under `--cli` the separator does **not** protect an argument from the Inspector — it does the opposite, and the web example above, run verbatim, would have `--config /etc/myserver.conf` consumed as a read-only-session flag (then rejected as a catalog/ad-hoc conflict). Leading-dash arguments for the server still get through; they just go on the other side of the separator, where the whole pre-`--` run is taken as the target verbatim: + +```bash +mcp-inspector --cli node build/index.js --config /etc/myserver.conf --verbose -- --method tools/list +``` + +Without a `--` on the line the target is only the leading run of **non-dash** tokens, so the separator is required whenever the server itself takes flags. ## The shared flags diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 04f2a640..e086a674 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -128,7 +128,7 @@ npx @modelcontextprotocol/inspector --catalog ./mcp.json npx @modelcontextprotocol/inspector ``` -**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. +**Rules:** `--catalog` and `--config` are mutually exclusive, and neither combines with an ad-hoc target (a positional command, `--server-url`, or `--transport`). v1 silently preferred the config file; v2 tells you. One exception: the **web** client exempts `--transport stdio` from that check, so it survives alongside `--catalog`/`--config` (ignored rather than rejected) — the CLI and TUI reject every `--transport`. If you export `MCP_CATALOG_PATH` in your shell, note that **web and TUI read it unconditionally** — so an ad-hoc invocation such as `mcp-inspector --tui node build/index.js` is rejected as a catalog/ad-hoc conflict. Unset it for that invocation. The CLI ignores the variable whenever an ad-hoc target is present. From ddfce0f7ac30cab4316809ae3d6d90f97a238d3f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:55:51 -0400 Subject: [PATCH 053/941] fix: reject a non-string protocolVersion, and drop the test double casts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review follow-ups: - `applyProtocolVersion` took the value straight from an unvalidated JSON body and went to `RegExp.test`, which coerces — `123` and `true` both matched the token pattern and reached `Transport.setProtocolVersion` with the wrong type. It now takes `unknown` and checks `typeof === "string"` first, with cases for the coercible values. - Replace `as unknown as Transport` in the new session fixtures with a `makeTransport()` helper returning a structurally valid `Transport`, so an interface change stays visible to the tests. - Drop `as unknown as typeof fetch` in the new transport test — `vi.fn<typeof fetch>()` is already the right type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../mcp/remote/remoteClientTransport.test.ts | 5 +--- .../mcp/remote/remote-session.test.ts | 23 +++++++++++++++---- core/mcp/remote/node/remote-session.ts | 9 +++++--- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts index 3c51abcb..c0478851 100644 --- a/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts +++ b/clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts @@ -542,10 +542,7 @@ describe("RemoteClientTransport", () => { return new Response("not found", { status: 404 }); }); - const transport = new RemoteClientTransport( - { baseUrl, fetchFn: fetchFn as unknown as typeof fetch }, - config, - ); + const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config); await transport.start(); // Pre-initialize sends carry no version (none negotiated yet). diff --git a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts index a74e980c..c152b960 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-session.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-session.test.ts @@ -25,6 +25,16 @@ function makeFetchEntry( }; } +/** A structurally valid `Transport`, so a fixture needs no cast. */ +function makeTransport(overrides: Partial<Transport> = {}): Transport { + return { + start: async () => {}, + send: async () => {}, + close: async () => {}, + ...overrides, + }; +} + describe("RemoteSession", () => { it("queues events before a consumer attaches and drains them on attach", () => { const session = new RemoteSession("s1"); @@ -125,7 +135,7 @@ describe("RemoteSession", () => { it("applyProtocolVersion forwards a new version to the transport once", () => { const session = new RemoteSession("s8a"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + session.setTransport(makeTransport({ setProtocolVersion })); session.applyProtocolVersion("2025-11-25"); session.applyProtocolVersion("2025-11-25"); @@ -136,21 +146,26 @@ describe("RemoteSession", () => { expect(setProtocolVersion).toHaveBeenLastCalledWith("2026-07-28"); }); - it("applyProtocolVersion ignores an absent or non-token version", () => { + it("applyProtocolVersion ignores an absent, non-token, or non-string version", () => { const session = new RemoteSession("s8b"); const setProtocolVersion = vi.fn(); - session.setTransport({ setProtocolVersion } as unknown as Transport); + session.setTransport(makeTransport({ setProtocolVersion })); session.applyProtocolVersion(undefined); // Header injection attempt — must never reach the upstream transport. session.applyProtocolVersion("2025-11-25\r\nX-Evil: 1"); session.applyProtocolVersion(""); + // The value arrives from an unvalidated JSON body, so a non-string must be + // rejected on its type — `RegExp.test` would coerce these into a match. + session.applyProtocolVersion(123); + session.applyProtocolVersion(true); + session.applyProtocolVersion(null); expect(setProtocolVersion).not.toHaveBeenCalled(); }); it("applyProtocolVersion is a no-op on a transport without setProtocolVersion (stdio)", () => { const session = new RemoteSession("s8c"); - session.setTransport({} as unknown as Transport); + session.setTransport(makeTransport()); expect(() => session.applyProtocolVersion("2025-11-25")).not.toThrow(); }); diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 60ee176f..fc7c7654 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -69,11 +69,14 @@ export class RemoteSession { * * Ignores anything that isn't a plausible version token so a client can't * push arbitrary bytes into an upstream header, and re-applies only on - * change (the value rides every `/api/mcp/send`). + * change (the value rides every `/api/mcp/send`). The value arrives from an + * unvalidated JSON body, so the type is checked rather than assumed — + * `RegExp.test` would otherwise coerce a `123` or `true` into a "matching" + * string and hand the wrong type to the transport. */ - applyProtocolVersion(version: string | undefined): void { + applyProtocolVersion(version: unknown): void { if ( - version === undefined || + typeof version !== "string" || version === this.appliedProtocolVersion || !RemoteSession.PROTOCOL_VERSION_PATTERN.test(version) ) { From 4fe02bca45d3e3e0fcb01169d3b4ddf96dde2084 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 20:59:38 -0400 Subject: [PATCH 054/941] docs: keep the Docker recipes loopback-only; note the legacy token fallback - The v1 recipe published on 127.0.0.1 and the v2 one silently dropped that. Docker's -p binds host interfaces independently of the container's HOST, and a no-Origin request skips the allow-list (core/mcp/remote/node/server.ts:241), so the token is the only guard on a process-spawning backend. Restore the loopback prefix on both recipes and say why. - MCP_PROXY_AUTH_TOKEN is not removed: web-server-config.ts:329 still falls back to it. Call it a deprecated fallback rather than a rename, and distinguish it from the query param, which has no fallback (App.tsx getAuthToken reads only the new name). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- docs/v1-to-v2-migration.md | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index e086a674..a2db3f55 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -268,21 +268,21 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th ## Environment variables -| v1 | v2 | Notes | -| ------------------------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working | -| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | -| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | -| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | -| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | -| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | -| `DANGEROUSLY_OMIT_AUTH` | same | | -| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | -| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | -| — | `MCP_CATALOG_PATH` | Default catalog path | -| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | -| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | -| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | +| v1 | v2 | Notes | +| ------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MCP_PROXY_AUTH_TOKEN` | **`MCP_INSPECTOR_API_TOKEN`** | Renamed, but the **old name still works** as a deprecated fallback when the new one is unset — an existing deployment keeps running while you migrate. Guards `/api/*` via `x-mcp-remote-auth: Bearer <token>`; the browser also receives it injected into `index.html`, so a bare reload keeps working. (The `?MCP_PROXY_AUTH_TOKEN=` **query param** has no such fallback — see [Web UI](#web-ui).) | +| `MCP_PROXY_FULL_ADDRESS` | — | Removed; there is no proxy | +| `SERVER_PORT` | _repurposed_ | Was the proxy port. Now only a fallback for the MCP Apps sandbox port | +| `CLIENT_PORT` | same | Web UI port, default `6274`. Must be a fixed port — `0`/dynamic is rejected, since the origin allow-list and sandbox CSP derive from it | +| `HOST` | same, **guarded** | An all-interfaces host (`0.0.0.0`, `::`, and equivalent spellings) is now **refused** unless `DANGEROUSLY_BIND_ALL_INTERFACES=true`. Binding a specific IP or hostname needs no opt-in | +| `ALLOWED_ORIGINS` | same | Still comma-separated, still **replaces** the default list rather than merging. Entries must include the scheme | +| `DANGEROUSLY_OMIT_AUTH` | same | | +| `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | +| — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | +| — | `MCP_CATALOG_PATH` | Default catalog path | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | +| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | ### The v1 UI configuration settings @@ -324,15 +324,17 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6277:6277 \ ghcr.io/modelcontextprotocol/inspector:1.0.1 # v2 — no proxy port; the image already sets the wildcard-bind opt-in -docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector:latest +docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:latest ``` Notes: +- **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. - **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: ```bash - docker run --rm -p 6274:6274 -p 6280:6280 -e MCP_SANDBOX_PORT=6280 \ + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ + -e MCP_SANDBOX_PORT=6280 \ ghcr.io/modelcontextprotocol/inspector:latest ``` From 35317fd9ed3062ec93c42a08ff429bc979028b0b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:00:31 -0400 Subject: [PATCH 055/941] chore(deps): scope the allowlist to a major, cover vitest.shared.mts, fix the vouch comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four suppressed comments from Copilot's third pass of #1962. Each was correct. The candidate boundary omitted `vitest.shared.mts` — root-owned, imported by every client's vitest config, and already treated as shared non-client source by `verify:typecheck-coverage`. It imports only Node built-ins today, which is exactly why the omission would have gone unnoticed until a third-party import appeared there, resolved from the root, and skewed. Shared *files* are now a first-class list beside the shared dirs. The allowlist was a blanket exemption by name. Each entry's rationale establishes that a patch/minor difference is benign, which is no evidence that a React 18-vs-19 or Hono 4-vs-5 split would be — that is a different type surface. Being listed now tolerates skew only *within a major version*; a cross-major skew fails like anything else. Deriving the major from the lockfile version keeps this automatic, with no per-package range to maintain and bump. A version that can't be parsed is treated as "cannot prove same major" and fails rather than passes. `verify-format-coverage.mjs`'s comment overstated the vouching graph: the three guards do not each check the others. This one checks both siblings; each sibling checks only this one. Described as it actually works. The AGENTS.md rule said to bump a shared dependency in "root + all four clients", but launcher declares no zod and `findSkew` ignores installs where a package is absent — so the rule as written asked for spurious dependencies. Reworded to every install that *declares* it, and mirrored in `.github/copilot-instructions.md`. The failure output follows: it no longer advises adding a package that is already allowlisted, saying "MAJOR skew" instead, and it names all three shared sources rather than the two dirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- scripts/verify-dep-lockstep.mjs | 85 ++++++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 85 +++++++++++++++++++++++++++- scripts/verify-format-coverage.mjs | 8 ++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a49cec61..38ae3ca9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. -- **A dependency bump must land in every install.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR touching one `package.json`/`package-lock.json` for a package `core/` or `test-servers/src` imports should touch all of them. +- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. diff --git a/AGENTS.md b/AGENTS.md index 1d38a6e2..2fcb3ab9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,7 +453,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages `core/` and `test-servers/src` import (so a new shared dependency is covered without editing the guard), reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **When bumping a dependency that `core/` or `test-servers/src` imports, bump it in every install** (root + all four clients), not just the one you're working in. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages the shared first-party TypeScript imports — `core/`, `test-servers/src`, and the individually-named `vitest.shared.mts` (root-owned and imported by every client's vitest config) — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources import, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0eb8a9dd..2e487fd8 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -21,8 +21,9 @@ // two copies — changing nothing else — returned the build to its baseline cost. // // The candidate set is DERIVED, not hand-listed: it is the packages imported by -// `core/` and `test-servers/src`, the two first-party surfaces compiled into -// more than one client's program. Skew is then denied by default, with a small +// the shared first-party TypeScript — `core/`, `test-servers/src`, and the +// root-owned `vitest.shared.mts` — the surfaces compiled into more than one +// client's program. Skew is then denied by default, with a small // allowlist of packages verified to tolerate it (below). A dependency that // starts skewing therefore fails `validate` and forces a decision, rather than // surfacing months later as an unexplained OOM. @@ -45,11 +46,25 @@ const repoRoot = path.resolve( // `test-servers/src` is pulled into the web and cli test projects. const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; +// Individual root-owned TypeScript files that are shared the same way but sit +// outside those trees. `vitest.shared.mts` is imported by every client's vitest +// config, and `verify:typecheck-coverage` already treats it as shared +// non-client source. It imports only Node built-ins today — which is precisely +// why omitting it would go unnoticed until a third-party import appeared there, +// resolved from the root, and skewed (Copilot, #1962). +const SHARED_SOURCE_FILES = ["vitest.shared.mts"]; + // Packages whose cross-install skew is verified benign, each with the reason. // This is an allowlist of *names*, not of version pairs, so an ordinary patch // float within one of these does not churn the file — while any package NOT // listed here failing the check is a genuine, unreviewed new skew. // +// Being listed is NOT a blanket exemption: it tolerates skew only *within a +// major version*. Each rationale below establishes that a patch/minor +// difference is harmless, which is not evidence that a React 18-vs-19 or Hono +// 4-vs-5 split across installs would be — that is a different type surface, and +// it fails like anything else (Copilot, #1962). +// // The admission test is the one the zod incident established: does the // package's public type surface consist of deeply recursive generics that // first-party code relates across the boundary? If yes it must stay in @@ -175,11 +190,34 @@ export function findSkew(candidates, installs) { return skewed; } -/** Split skewed packages into the tolerated ones and the failures. */ +/** + * The major-version component of a lockfile version string. Prerelease and + * build metadata are irrelevant here (`2.0.0-beta.5` → `2`). Returns null for + * anything not starting with an integer, which is treated as "cannot prove same + * major" and therefore fails rather than passes. + */ +export function majorOf(version) { + const m = /^(\d+)\./.exec(String(version ?? "")); + return m ? m[1] : null; +} + +/** + * Split skewed packages into the tolerated ones and the failures. + * + * Being on the allowlist tolerates skew only *within a major version*: each + * entry's rationale establishes that a patch/minor difference is benign, which + * says nothing about a major split, where the type surface itself changes. So a + * listed package whose holders disagree on major is still a failure. + */ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { + const isTolerated = (s) => { + if (!tolerated.has(s.name)) return false; + const majors = new Set(s.holders.map((h) => majorOf(h.version))); + return majors.size === 1 && !majors.has(null); + }; return { - failures: skewed.filter((s) => !tolerated.has(s.name)), - ignored: skewed.filter((s) => tolerated.has(s.name)), + failures: skewed.filter((s) => !isTolerated(s)), + ignored: skewed.filter(isTolerated), }; } @@ -191,8 +229,12 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { // unnoticed until a new shared dependency arrived through one and skewed. const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; -/** Whether a repo-relative path is a TypeScript source of a shared tree. */ +/** + * Whether a repo-relative path is shared first-party TypeScript — under one of + * the shared trees, or one of the individually-named shared files. + */ export function isSharedSourceFile(file) { + if (SHARED_SOURCE_FILES.includes(file)) return true; if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; // Anchored on a path boundary so a sibling whose name merely starts with a // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. @@ -203,7 +245,12 @@ export function isSharedSourceFile(file) { function sharedSourceFiles() { const out = execFileSync( "git", - ["ls-files", "--", ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`)], + [ + "ls-files", + "--", + ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), + ...SHARED_SOURCE_FILES, + ], { cwd: repoRoot, encoding: "utf8" }, ); return out.split("\n").filter(isSharedSourceFile); @@ -278,22 +325,36 @@ export function main() { console.error( `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, ); + let anyListed = false; for (const { name, holders } of failures) { - console.error(` ${name}`); + // A package already on the allowlist reached here only by skewing across + // a MAJOR boundary, so say that rather than advising an entry that exists. + const listed = TOLERATED_SKEW.has(name); + anyListed ||= listed; + console.error( + ` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, + ); for (const { dir, version } of holders) console.error(` ${version} (${dir})`); } + const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", "); console.error( "\nThese packages' types are compiled into a single `tsc` program from two installs" + - `\n(${SHARED_SOURCE_DIRS.join(" and ")} resolve from the root, a client's own sources from the client),` + + `\n(${shared} resolve from the root, a client's own sources from the client),` + "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", ); console.error( - "\nAlign them — `npm install <pkg>@<version>` in each install so all lockfiles agree — or, if this" + - "\npackage's types genuinely cannot blow up, add it to TOLERATED_SKEW in scripts/verify-dep-lockstep.mjs" + - "\nwith the reason. See AGENTS.md.", + "\nAlign them — `npm install <pkg>@<version>` in each install that declares the package, so all" + + "\nlockfiles agree. (Don't add it to an install that doesn't declare it: a package absent from an" + + "\ninstall can't skew.) If instead its types genuinely cannot blow up, add it to TOLERATED_SKEW in" + + "\nscripts/verify-dep-lockstep.mjs with the reason. See AGENTS.md.", ); + if (anyListed) + console.error( + "\nNote: an allowlisted package is tolerated only WITHIN a major version — the rationale for one" + + "\nestablishes that a patch/minor difference is benign, not that a major split is. Align the major.", + ); process.exit(1); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 890d90b5..55704578 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -10,6 +10,7 @@ import { findSkew, importedPackageNames, isSharedSourceFile, + majorOf, packageNameOf, partitionSkew, topLevelLockVersions, @@ -240,8 +241,20 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { // So an ordinary patch float within a tolerated package does not churn the // allowlist, while any *unlisted* package that starts skewing still fails. const skewed = [ - { name: "react", holders: [] }, - { name: "zod", holders: [] }, + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + { + name: "zod", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + ], + }, ]; const tolerated = new Map([["react", "shallow interfaces"]]); const { failures, ignored } = partitionSkew(skewed, tolerated); @@ -256,6 +269,72 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { }); test("partitionSkew: deny by default — nothing tolerated fails everything", () => { - const skewed = [{ name: "zod", holders: [] }]; + const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }]; assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); }); + +test("partitionSkew: the allowlist tolerates skew only within a major (Copilot, #1962)", () => { + // Each rationale establishes that a patch/minor difference is benign; that is + // not evidence a React 18-vs-19 split is, so a listed package still fails + // across a major boundary. + const tolerated = new Map([["react", "shallow interfaces"]]); + const withinMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + const acrossMajor = [ + { + name: "react", + holders: [ + { dir: ".", version: "18.3.1" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, + ]; + assert.equal(partitionSkew(withinMajor, tolerated).failures.length, 0); + assert.equal(partitionSkew(withinMajor, tolerated).ignored.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).failures.length, 1); + assert.equal(partitionSkew(acrossMajor, tolerated).ignored.length, 0); +}); + +test("partitionSkew: an unparseable version can't be proven same-major, so it fails", () => { + const tolerated = new Map([["react", "shallow interfaces"]]); + const skewed = [ + { + name: "react", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "next" }, + ], + }, + ]; + assert.equal(partitionSkew(skewed, tolerated).failures.length, 1); +}); + +test("majorOf: prerelease and build metadata are irrelevant", () => { + const cases = [ + ["4.4.3", "4"], + ["2.0.0-beta.5", "2"], + ["19.2.8", "19"], + ["1.10.3+build.7", "1"], + ]; + for (const [input, expected] of cases) + assert.equal(majorOf(input), expected, input); + for (const bad of ["next", "", undefined, null, "v4.4.3"]) + assert.equal(majorOf(bad), null, JSON.stringify(bad)); +}); + +test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => { + // `vitest.shared.mts` is root-owned, imported by every client's vitest + // config, and already treated as shared by `verify:typecheck-coverage`. It + // imports only Node built-ins today, which is why omitting it would go + // unnoticed until a third-party import appeared there and skewed. + assert.equal(isSharedSourceFile("vitest.shared.mts"), true); + // Still anchored: a same-named file nested elsewhere is not the shared one. + assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false); +}); diff --git a/scripts/verify-format-coverage.mjs b/scripts/verify-format-coverage.mjs index 014b850f..20ea65db 100644 --- a/scripts/verify-format-coverage.mjs +++ b/scripts/verify-format-coverage.mjs @@ -166,9 +166,11 @@ function trackedSourceFiles() { return out.split("\n").filter(Boolean); } -// Vouch for the sibling guards: a guard can't detect being unrun itself, but the -// three can each assert the others are still wired into `validate`, so dropping -// any one is caught here. Only deleting all of them slips through. +// Vouch for the sibling guards: a guard can't detect being unrun itself, so the +// three form a cycle instead. This one checks BOTH others; each of them checks +// only this one. So dropping `verify:typecheck-coverage` or +// `verify:dep-lockstep` is caught here, and dropping *this* guard is caught by +// either of them. Only removing all three at once slips through. const rootScripts = JSON.parse( readFileSync(path.join(repoRoot, "package.json"), "utf8"), ).scripts; From 2dcf1450b21e76be4d89d61c74ff300dffece25e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:07:37 -0400 Subject: [PATCH 056/941] chore(deps): see imports through comment trivia; name vitest.shared.mts in the README Both suppressed comments from Copilot's fourth pass of #1962. TypeScript allows a comment anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` and `from /* why */ "pkg"` are valid imports the patterns stepped over. That is the dangerous direction: a missed specifier never enters the candidate set, so the package's skew passes the guard silently, against the over-approximation the module claims. A `TRIVIA` fragment now stands in for `\s*` at every inter-token position, with regression cases for the commented static, dynamic, side-effect, and `require` forms. I did not move to the TypeScript parser. This is a root `scripts/` `.mjs` tool with no bundler and no TS dependency; pulling in a compiler API to read import specifiers is a lot of machinery for a scan that only needs to over-approximate, and each missed form is a one-line pattern with a test beside it. The derived set is unchanged at 18 real names, so nothing here widens what is checked today. The README's command row also still described the candidate scan as `core/` and `test-servers/src` only, omitting the `vitest.shared.mts` the guard now includes; it also never mentioned that an allowlisted package is tolerated only within a major. Both stated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- README.md | 2 +- scripts/verify-dep-lockstep.mjs | 24 ++++++++++++++++++++---- scripts/verify-dep-lockstep.test.mjs | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fef78256..cf163f54 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles `core/` and `test-servers/src` — which resolve from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what `core/` and `test-servers/src` import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist. Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 2e487fd8..0a328e44 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -130,11 +130,27 @@ export function packageNameOf(specifier) { // `tools`…" would otherwise enter the candidate set as a package named // `tools`. Inert today, but a prose word that collides with a real package // name would silently widen the set. +// +// Whitespace between tokens is really *trivia*: TypeScript allows a comment +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` +// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, +// #1962). `TRIVIA` stands in for `\s*` at every such position. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; const SPECIFIER_FORMS = [ - /\bfrom\s*["']([^"']+)["']/g, // import … from "x" / export … from "x" - /\bimport\s+["']([^"']+)["']/g, // side-effect import "x" - /\bimport\s*\(\s*["'`]([^"'`]+)["'`]/g, // dynamic import("x"[, opts]) - /\brequire\s*\(\s*["'`]([^"'`]+)["'`]/g, // import x = require("x"), require("x") + // import … from "x" / export … from "x" + new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), + // side-effect import "x" + new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), + // dynamic import("x"[, opts]) + new RegExp( + String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), + // import x = require("x"), require("x") + new RegExp( + String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, + "g", + ), ]; /** diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 55704578..6d0840d7 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -100,6 +100,24 @@ test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, ]); }); +test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => { + // TypeScript allows a comment anywhere whitespace is legal, so all of these + // are valid imports. Missing one is the dangerous direction: the package + // never enters the candidate set and its skew passes the guard silently. + const source = ` + import { a } from /* explanation */ "express"; + const b = await import(/* webpackIgnore: true */ "undici"); + const c = require(/* lazy */ "yaml"); + import /* side effect */ "pino"; + `; + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "pino", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From 2f1d97e7bbf4dc38de7cf0f47bab8fa74e202e07 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:13:56 -0400 Subject: [PATCH 057/941] chore(deps): treat line comments as trivia too in the import scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifth pass of #1962. `TRIVIA` covered block comments but not `//`, despite the contract right above it saying a comment is legal wherever whitespace is — so `import(// lazy\n"pkg")`, `require(// lazy\n"pkg")`, and `from // reason\n"pkg"` were stepped over, and such a package would never enter the candidate set. Same silent-miss direction as the block-comment gap. Both comment syntaxes are now covered. The line-comment branch runs to end-of-line only; the newline itself is matched by TRIVIA's `\s` branch. Regression test added alongside the block-comment one. The derived set is unchanged at 18 real names — widening trivia does not admit prose, since a specifier must still follow immediately and `packageNameOf` rejects anything not shaped like one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 10 ++++++---- scripts/verify-dep-lockstep.test.mjs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 0a328e44..5d29497b 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -132,10 +132,12 @@ export function packageNameOf(specifier) { // name would silently widen the set. // // Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")` -// and `from /* why */ "pkg"` are valid and must still be seen (Copilot, -// #1962). `TRIVIA` stands in for `\s*` at every such position. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/)*`; +// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, +// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) +// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for +// `\s*` at every such position and covers both comment syntaxes. A line comment +// runs to end-of-line only — the newline itself is matched by the `\s` branch. +const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; const SPECIFIER_FORMS = [ // import … from "x" / export … from "x" new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6d0840d7..eee81432 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -118,6 +118,25 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () ]); }); +test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { + // `//` runs to end-of-line and is legal in every position a block comment is, + // so these are valid imports too. The newline is matched by TRIVIA's `\\s` + // branch rather than by the line-comment branch. + const source = [ + "import { a } from // reason", + ' "express";', + "const b = await import(// lazy", + ' "undici");', + "const c = require(// lazy", + ' "yaml");', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "express", + "undici", + "yaml", + ]); +}); + test("importedPackageNames: the three specifier forms that introduce types", () => { const source = ` import { z } from "zod/v4"; From fcd45037c9c012486f548ed43265c2319d79340a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:24:55 -0400 Subject: [PATCH 058/941] chore(deps): extract specifiers with ts.preProcessFile; document the transitive boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's sixth pass of #1962. Take the TypeScript-parser suggestion, which was raised across three rounds and was right. The regex scan got both directions wrong and every widening traded one failure for the other: it *missed* valid syntax (`import x = require(…)`, an import-attributes argument, a comment between tokens — each a silent miss, so the package never entered the candidate set and its skew passed), and it *invented* names from prose, since `// adapted from "react"` is indistinguishable from an import to a pattern that cannot tell code from a comment. The earlier prose test only passed because `cwd omitted` isn't a valid package name. `ts.preProcessFile` is the cheap answer I'd missed while arguing against the full AST: TypeScript's lightweight pre-parse scanner, no parse tree and no type checking, purpose-built to return module specifiers. It handles every import form, trivia, strings, and regex literals, and never reads a comment as code. typescript resolves from `clients/web`, which already carries it, via the `createRequire` base `smoke-web-browser.mjs` established — a bare import would resolve relative to `scripts/`, not the cwd. The derived set drops 18 → 17 real names: every genuine package is retained and the one prose artifact is gone. Regression tests now use *real* package names in comments and strings, the case the old test couldn't cover. Also records the KNOWN BOUNDARY the review's other finding identified. Candidates cover what the shared sources name directly, so a package reaching the program only through another package's `.d.ts` is invisible — `@modelcontextprotocol/sdk` is the live example, skewed 1.29.0 vs 1.30.0 and present in web's program from both installs, never written in first-party code. Closing it properly changes what the guard measures, so it is tracked as #1965, with both candidate derivations measured there: a lockfile closure is unusable (155 packages, 25 skewed, nearly all irrelevant tooling, and it misses the SDK anyway), while `tsc --listFilesOnly` is correct and small (15 for clients/web). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 99 +++++++++++++++------------- scripts/verify-dep-lockstep.test.mjs | 26 ++++---- 2 files changed, 66 insertions(+), 59 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 5d29497b..4eb32d9a 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -27,10 +27,25 @@ // allowlist of packages verified to tolerate it (below). A dependency that // starts skewing therefore fails `validate` and forces a decision, rather than // surfacing months later as an unexplained OOM. +// +// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources +// name *directly*. A package whose declarations reach the program only through +// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is +// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in +// web's program from both installs, yet never written in first-party code +// (the shared sources import the split `@modelcontextprotocol/client|core|…`). +// Two derivations were measured for closing this. A lockfile dependency +// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant +// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading +// what actually lands in each program (`tsc --listFilesOnly`, keeping packages +// present under two install roots) is both correct and small: 15 for +// `clients/web`, ~10 once nested duplicates are dropped. That is the right +// derivation and is tracked separately, since it changes what the guard +// measures and surfaces skews needing their own decisions. import { readFileSync, existsSync, readdirSync } from "node:fs"; import { execFileSync } from "node:child_process"; -import { builtinModules } from "node:module"; +import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { rootReachesScript } from "./lib/npm-scripts.mjs"; @@ -116,59 +131,51 @@ export function packageNameOf(specifier) { return name; } -// Every form that can introduce a dependency's *types* (Copilot, #1962). -// `import x = require("pkg")` and a bare `require("pkg")` matter because the -// shared trees may hold `.cts` sources, where that is the ordinary import -// syntax; and neither call form requires the closing paren, so an import -// attributes argument (`import("pkg", { with: … })`) can't hide the specifier. +// Specifiers are extracted with TypeScript's own `preProcessFile` rather than +// by regex (Copilot, #1962 — raised across three review rounds, and correctly). +// A regex scan gets both directions wrong: it *misses* valid syntax (an +// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment +// between tokens — each a silent miss, so the package never enters the +// candidate set and its skew passes), and it *invents* names from prose, since +// `// adapted from "react"` is indistinguishable from an import to a pattern +// that can't tell code from a comment. Every widening of the regex traded one +// of those failures for the other. // -// Backticks are accepted ONLY in the call forms, where a static template -// literal is legal. A `from` clause requires a string literal, so allowing -// backticks there would buy nothing while reopening the prose hazard the -// anchored specifier pattern exists to close — inline code in a comment is -// written with backticks throughout this codebase, and "…derived from -// `tools`…" would otherwise enter the candidate set as a package named -// `tools`. Inert today, but a prose word that collides with a real package -// name would silently widen the set. +// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full +// parse and no type checking — and it is exactly built for this: it returns +// every module specifier, handling all import forms, trivia, strings, and +// regex literals correctly, and it never sees a comment as code. // -// Whitespace between tokens is really *trivia*: TypeScript allows a comment -// anywhere whitespace is legal, so `import(/* webpackIgnore: true */ "pkg")`, -// `from /* why */ "pkg"`, and the line-comment forms (`import(// lazy\n"pkg")`) -// are all valid and must still be seen (Copilot, #1962). `TRIVIA` stands in for -// `\s*` at every such position and covers both comment syntaxes. A line comment -// runs to end-of-line only — the newline itself is matched by the `\s` branch. -const TRIVIA = String.raw`(?:\s|/\*[\s\S]*?\*/|//[^\n]*)*`; -const SPECIFIER_FORMS = [ - // import … from "x" / export … from "x" - new RegExp(String.raw`\bfrom${TRIVIA}["']([^"']+)["']`, "g"), - // side-effect import "x" - new RegExp(String.raw`\bimport${TRIVIA}["']([^"']+)["']`, "g"), - // dynamic import("x"[, opts]) - new RegExp( - String.raw`\bimport${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), - // import x = require("x"), require("x") - new RegExp( - String.raw`\brequire${TRIVIA}\(${TRIVIA}["'\`]([^"'\`]+)["'\`]`, - "g", - ), -]; +// typescript is resolved from `clients/web`, which already carries it; the root +// has no TS dependency of its own. The `createRequire` base is load-bearing — +// a bare `import("typescript")` would resolve relative to `scripts/`, not the +// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way). +let tsCache; +function typescript() { + if (!tsCache) { + const require_ = createRequire( + path.join(repoRoot, "clients", "web", "package.json"), + ); + tsCache = require_("typescript"); + } + return tsCache; +} /** * Every third-party package name imported by a blob of TypeScript source. - * Deliberately a regex scan rather than a parse: it only needs to over- rather - * than under-approximate, since a name absent from every lockfile contributes - * nothing downstream (`@inspector/core` is a build-time alias, not a package, - * and drops out that way). + * Over-approximating is safe (a name absent from every lockfile contributes + * nothing downstream — `@inspector/core` is a build-time alias, not a package, + * and drops out that way); under-approximating is not, since a missed package + * never enters the candidate set and its skew would pass silently. */ export function importedPackageNames(source) { const names = new Set(); - for (const re of SPECIFIER_FORMS) { - for (const m of source.matchAll(re)) { - const name = packageNameOf(m[1]); - if (name) names.add(name); - } + // (source, readImportFiles, detectJavaScriptImports) — the latter two make it + // report `require(…)` and dynamic imports as well as static ones. + const { importedFiles } = typescript().preProcessFile(source, true, true); + for (const { fileName } of importedFiles) { + const name = packageNameOf(fileName); + if (name) names.add(name); } return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index eee81432..6fd9ecac 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -155,26 +155,26 @@ test("importedPackageNames: the three specifier forms that introduce types", () ]); }); -test("importedPackageNames: prose after `from` is not an import", () => { - // The scan over-approximates on purpose, but must not invent package names - // out of comment text — a bogus name absent from every lockfile is inert, - // yet a *plausible* one would silently widen the candidate set. +test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { + // The regex scan this replaced could not tell code from a comment, so + // `// adapted from "react"` added `react` to the candidate set — and if that + // installed package were skewed, an unrelated comment would fail `validate`. + // These use REAL package names, which is the case the old prose test missed: + // it only passed because `cwd omitted` isn't a valid package name. const source = ` - // Resolved relative to the runner dir, not from "cwd omitted" by the caller. - /** Reads the manifest from "file:" URLs. */ + // adapted from "react" + /** Mirrors the behavior of "express", see require("yaml") below. */ + /** The excluded set derived from \\\`hono\\\`-style paths. */ + // const disabled = await import("undici"); import { z } from "zod"; `; assert.deepEqual([...importedPackageNames(source)], ["zod"]); }); -test("importedPackageNames: a backticked `from` in prose is not an import", () => { - // Backticks are legal in the *call* forms (a static template literal) but - // never after `from`, which requires a string literal. Accepting them there - // would sweep in inline code from comments — this codebase writes it with - // backticks throughout — and a prose word colliding with a real package name - // would silently widen the candidate set. +test("importedPackageNames: a specifier inside a string literal is not an import", () => { const source = ` - /** The excluded set derived from \\\`tools\\\`, keyed off \\\`express\\\`-style paths. */ + const msg = 'run require("chokidar") to load it'; + const re = /"jose"/; import { z } from "zod"; `; assert.deepEqual([...importedPackageNames(source)], ["zod"]); From 3638185e0ffa489419b6e4247c1431374ea500f0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:33:37 -0400 Subject: [PATCH 059/941] chore(deps): fail with a cause when typescript can't be resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard resolves typescript from `clients/web`. The realistic way for that to fail is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, which leaves `clients/web/node_modules` empty — and a bare MODULE_NOT_FOUND stack gives no hint that the fix is `npm install` at the root. Throw with that guidance and the original error as `cause`. Deliberately not a silent skip: an unrun guard guards nothing, which is the failure mode the sibling-vouch ring exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 4eb32d9a..8d20c206 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -156,7 +156,20 @@ function typescript() { const require_ = createRequire( path.join(repoRoot, "clients", "web", "package.json"), ); - tsCache = require_("typescript"); + try { + tsCache = require_("typescript"); + } catch (cause) { + // Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to + // get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, + // which leaves `clients/web/node_modules` empty. Silently skipping the + // check instead would be worse — an unrun guard guards nothing. + throw new Error( + "verify:dep-lockstep — could not resolve `typescript` from clients/web. " + + "Run `npm install` at the repo root (the postinstall cascade installs each client); " + + "if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.", + { cause }, + ); + } } return tsCache; } From 48d74ba423e83b8d6ad3f207913e42bfa055c28f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:41:31 -0400 Subject: [PATCH 060/941] chore(deps): check each shared source contributed, not the aggregate count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eighth pass of #1962. The no-op guard tested only `files.length === 0`, which stopped meaning anything once `SHARED_SOURCE_FILES` was added: `vitest.shared.mts` alone keeps the total nonzero, so a moved or renamed `core/` would leave the guard deriving candidates from a near-empty set and passing on skew it exists to catch. `sourcesWithNoFiles` now requires every configured dir and every named file to have contributed, and names the ones that didn't. Directory matching is on a path separator, so a prefix sibling (`core-internal/`) can't vouch for a renamed `core/`. Also drops two test comments left stale by the move to `ts.preProcessFile` — they still described the deleted TRIVIA regex. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 31 +++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 46 ++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 8d20c206..de4cccf0 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -279,6 +279,23 @@ export function isSharedSourceFile(file) { return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); } +/** + * Which configured shared sources contributed no file to `files`. Each dir and + * each individually-named file must be represented; an aggregate count can't + * see one of them going missing, because the others keep the total nonzero. + */ +export function sourcesWithNoFiles( + files, + dirs = SHARED_SOURCE_DIRS, + named = SHARED_SOURCE_FILES, +) { + const missingDirs = dirs.filter( + (dir) => !files.some((f) => f.startsWith(`${dir}/`)), + ); + const missingNamed = named.filter((name) => !files.includes(name)); + return [...missingDirs, ...missingNamed]; +} + /** Tracked TypeScript files under the shared first-party source trees. */ function sharedSourceFiles() { const out = execFileSync( @@ -334,9 +351,19 @@ export function main() { } const files = sharedSourceFiles(); - if (files.length === 0) { + // Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts` + // alone keeps the total nonzero, so a moved or renamed `core/` would leave + // the guard checking a near-empty candidate set and passing. Every configured + // source must contribute, or the enumeration is broken. + const empty = sourcesWithNoFiles(files); + if (empty.length > 0) { console.error( - `verify:dep-lockstep — found no tracked sources under ${SHARED_SOURCE_DIRS.join(", ")}. The guard would check nothing; fix the enumeration.`, + `verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`, + ); + for (const source of empty) console.error(` ${source}`); + console.error( + "\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." + + "\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.", ); process.exit(1); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 6fd9ecac..89268ee4 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -13,6 +13,7 @@ import { majorOf, packageNameOf, partitionSkew, + sourcesWithNoFiles, topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; @@ -47,6 +48,46 @@ test("isSharedSourceFile: non-TS files and other trees are excluded", () => { assert.equal(isSharedSourceFile(file), false, file); }); +test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => { + const dirs = ["core", "test-servers/src"]; + const named = ["vitest.shared.mts"]; + const complete = [ + "core/mcp/a.ts", + "test-servers/src/b.ts", + "vitest.shared.mts", + ]; + assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []); + + // The failure an aggregate count can't see: `core/` moved, but the other two + // sources keep `files.length` nonzero, so the guard would derive candidates + // from an incomplete set and pass on skew it should catch. + assert.deepEqual( + sourcesWithNoFiles( + ["test-servers/src/b.ts", "vitest.shared.mts"], + dirs, + named, + ), + ["core"], + ); + assert.deepEqual( + sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named), + ["vitest.shared.mts"], + ); + assert.deepEqual(sourcesWithNoFiles([], dirs, named), [ + "core", + "test-servers/src", + "vitest.shared.mts", + ]); +}); + +test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => { + // `core-internal/` starts with `core` but is not it — the boundary check has + // to be on a path separator, or a renamed dir would look present. + assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [ + "core", + ]); +}); + test("packageNameOf: bare names, scopes, and subpaths", () => { const cases = [ ["zod", "zod"], @@ -120,8 +161,7 @@ test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { // `//` runs to end-of-line and is legal in every position a block comment is, - // so these are valid imports too. The newline is matched by TRIVIA's `\\s` - // branch rather than by the line-comment branch. + // so a specifier can sit on the next line and these are still valid imports. const source = [ "import { a } from // reason", ' "express";', @@ -137,7 +177,7 @@ test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962) ]); }); -test("importedPackageNames: the three specifier forms that introduce types", () => { +test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => { const source = ` import { z } from "zod/v4"; export type { Foo } from '@modelcontextprotocol/core'; From 2f2d545751fee1e9710d26b460e58411896808fb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:48:13 -0400 Subject: [PATCH 061/941] docs: narrow the dep-lockstep summaries to the invariant it actually proves From Copilot's ninth pass of #1962. The one-line descriptions in AGENTS.md and the README claimed "no install-crossing dependency resolves to two different versions", which is stronger than the gate: candidates cover only packages the shared sources name directly, and #1965 records a live counterexample (`@modelcontextprotocol/sdk`, skewed across two install roots while the guard stays green). The boundary was already documented at length on the guard and in the AGENTS.md rule; the summaries just hadn't been narrowed to match, which is how a doc ends up asserting a guarantee nobody implemented. Both now say "directly imported" and point at #1965. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- AGENTS.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fcb3ab9..3da1011e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,7 +448,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab ### Mandatory pre-push gate - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no install-crossing dependency resolves to two different versions), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency *directly imported* by the shared sources resolves to two different versions across installs; transitive-only declarations are a known boundary, #1965), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib *resolution* options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is *wired* (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently *skips* a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the *global* union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). diff --git a/README.md b/README.md index cf163f54..126c76a0 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no install-crossing dependency skews) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | From 32c827f0895b3db0d3948d73158dc741b3576d4a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 21:55:49 -0400 Subject: [PATCH 062/941] test(scripts): exercise the dep-lockstep guard's executable path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's tenth pass of #1962. The suite covered only pure helpers, so a regression in source enumeration, install discovery, lockfile loading, the sibling-guard vouch, or the nonzero exit on real skew would have left it green — and the negative cases had only ever been run by hand, once per review round. Five end-to-end cases build a throwaway repo and run the guard as a subprocess: aligned installs exit 0; a skewed one exits 1 naming the package and every holder while leaving the agreeing package unreported; a package imported only by `test-servers/src` still fails (so dropping the second shared tree can't pass); a shared source matching no tracked file fails; and a `validate` chain that stopped running `verify:format-coverage` fails the vouch even when the versions are fine. The fixture must be realpath'd. On macOS `tmpdir()` is `/var/…`, a symlink to `/private/var/…`, and the guard runs `main()` only when `import.meta.url` (the resolved path) matches `process.argv[1]` — so launching it via the unresolved path loads the module and does nothing, exiting 0 silently. That is how all five first "passed" as no-ops, and the comment records it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 203 ++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 scripts/verify-dep-lockstep.main.test.mjs diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs new file mode 100644 index 00000000..6321d24e --- /dev/null +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -0,0 +1,203 @@ +// End-to-end tests for the dep-lockstep guard's executable path (Copilot, +// #1962). The sibling tests cover the pure helpers; nothing exercised `main()`, +// so a regression in source enumeration, install discovery, lockfile loading, +// the sibling-guard vouch, or the nonzero exit on real skew would have left the +// whole suite green. +// +// Each case builds a throwaway repo — the guard derives its root from its own +// file location, so the script is copied into the fixture rather than pointed +// at one — `git add`s it (the enumeration is `git ls-files`, which reads the +// index), and runs the guard as a subprocess to assert the exit status and +// message. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(scriptsDir, ".."); + +/** The real typescript install, symlinked into each fixture so the guard resolves it. */ +const typescriptDir = path.dirname( + createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve( + "typescript/package.json", + ), +); + +const lock = (deps) => ({ + lockfileVersion: 3, + packages: Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), +}); + +/** + * Build a fixture repo: shared sources importing `zod` and `express`, a root + * install and one client install, and the guard itself. `rootDeps`/`webDeps` + * decide whether the two installs agree. + */ +function makeFixture({ rootDeps, webDeps, scripts }) { + // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to + // `/private/var/...`. The guard only runs `main()` when `import.meta.url` + // (always the resolved path) matches `process.argv[1]`, so launching it via + // the unresolved path would load the module and silently do nothing — + // exiting 0 with no output, which every assertion here would misread. + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "dep-lockstep-"))); + const write = (rel, contents) => { + mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true }); + writeFileSync( + path.join(dir, rel), + typeof contents === "string" + ? contents + : JSON.stringify(contents, null, 2), + ); + }; + + write("package.json", { + name: "fixture", + scripts: scripts ?? { + validate: "npm run verify:format-coverage && npm run verify:dep-lockstep", + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }); + write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n'); + write( + "test-servers/src/server.ts", + 'import express from "express";\nexport const app = express;\n', + ); + write( + "vitest.shared.mts", + 'import path from "node:path";\nexport default path;\n', + ); + write("package-lock.json", lock(rootDeps)); + write("clients/web/package.json", { name: "web" }); + write("clients/web/package-lock.json", lock(webDeps)); + + // The guard resolves its repo root from its own location, so it has to live + // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-dep-lockstep.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + + mkdirSync(path.join(dir, "node_modules"), { recursive: true }); + symlinkSync( + typescriptDir, + path.join(dir, "node_modules", "typescript"), + "dir", + ); + + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + return dir; +} + +function runGuard(dir) { + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-dep-lockstep.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; +} + +/** Run `fn` against a fresh fixture, always cleaning the temp dir up. */ +function withFixture(options, fn) { + const dir = makeFixture(options); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const ALIGNED = { zod: "4.4.3", express: "5.2.1" }; + +test("main: exits 0 when every install agrees", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.match(out, /verify:dep-lockstep — OK/); + // Both shared trees and the named shared file contributed their imports. + assert.match(out, /2 install-crossing dependencies/); + }); +}); + +test("main: exits 1 and names the skewed package and every holder", () => { + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bzod\b/); + assert.match(out, /4\.3\.6\s+\(\.\)/); + assert.match(out, /4\.4\.3\s+\(clients\/web\)/); + // `express` agrees, so it must not be reported. + assert.doesNotMatch(out, /^\s+express$/m); + }, + ); +}); + +test("main: a package imported only by test-servers/src is still checked", () => { + // Guards the enumeration of the *second* shared tree: if only `core/` were + // scanned, this skew would pass. + withFixture( + { rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /\bexpress\b/); + }, + ); +}); + +test("main: exits 1 when a configured shared source matches no file", () => { + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + // Drop `core/` from the index — the other sources keep the file count + // nonzero, which is exactly what the old aggregate check missed. + execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir }); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /matched no tracked file/); + assert.match(out, /^\s+core$/m); + }); +}); + +test("main: exits 1 when the root validate no longer runs the sibling guard", () => { + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + // `verify:format-coverage` dropped from the chain: the vouch must fail + // even though the dependency versions themselves are fine. + scripts: { + validate: "npm run verify:dep-lockstep", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:format-coverage`/); + }, + ); +}); From af63a078179c3bb51be7f3667928db5e4341785a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:02:56 -0400 Subject: [PATCH 063/941] chore(deps): reject an unreadable lockfile instead of failing open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's eleventh pass of #1962, and the most consequential kind of bug for a gate: it failed *open*. `topLevelLockVersions` returns an empty map for anything without a v2+ `packages` table — a lockfileVersion 1 file, or a malformed one. `main()` fed that straight into the comparison, so such an install contributed no holders at all, and a genuine skew among the remaining installs was reported as aligned. The guard would have said OK to exactly what it exists to catch. `hasReadableLockShape` now gates every discovered lockfile before any comparison, requiring a `packages` object carrying the `""` root entry npm always writes — "has a packages key" is not enough. An unreadable one is named and exits 1, telling you to regenerate it. A JSON parse failure likewise reports the file rather than dying on a bare SyntaxError with no path in it. The empty-map behavior of the pure helper is kept and its test now says why it is safe: the shape check rejects those inputs before they can reach `findSkew`. Three regression cases: a v1 lockfile in a fixture that IS skewed (so failing open would show as a pass), a `packages` table with no root entry, and the unit table for the predicate. Fixtures now write the `""` root entry, matching real npm output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 62 ++++++++++++++++++++--- scripts/verify-dep-lockstep.mjs | 60 +++++++++++++++++++--- scripts/verify-dep-lockstep.test.mjs | 29 +++++++++++ 3 files changed, 137 insertions(+), 14 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 6321d24e..50db6b99 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -37,14 +37,18 @@ const typescriptDir = path.dirname( ), ); +/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ const lock = (deps) => ({ lockfileVersion: 3, - packages: Object.fromEntries( - Object.entries(deps).map(([name, version]) => [ - `node_modules/${name}`, - { version }, - ]), - ), + packages: { + "": { name: "fixture" }, + ...Object.fromEntries( + Object.entries(deps).map(([name, version]) => [ + `node_modules/${name}`, + { version }, + ]), + ), + }, }); /** @@ -52,7 +56,7 @@ const lock = (deps) => ({ * install and one client install, and the guard itself. `rootDeps`/`webDeps` * decide whether the two installs agree. */ -function makeFixture({ rootDeps, webDeps, scripts }) { +function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` // (always the resolved path) matches `process.argv[1]`, so launching it via @@ -88,7 +92,7 @@ function makeFixture({ rootDeps, webDeps, scripts }) { ); write("package-lock.json", lock(rootDeps)); write("clients/web/package.json", { name: "web" }); - write("clients/web/package-lock.json", lock(webDeps)); + write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps)); // The guard resolves its repo root from its own location, so it has to live // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. @@ -182,6 +186,48 @@ test("main: exits 1 when a configured shared source matches no file", () => { }); }); +test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => { + // A v1 lockfile has `dependencies` and no `packages` table. Treating it as an + // install that simply holds nothing would leave the *root's* zod unopposed + // and the skew below reported as aligned — the gate failing open. + withFixture( + { + rootDeps: { ...ALIGNED, zod: "4.3.6" }, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 1, + dependencies: { zod: { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 on a lockfile with a `packages` table but no root entry", () => { + // The shape check is not just "has a packages key" — a table without the + // `""` root npm always writes is not a lockfile this guard can trust. + withFixture( + { + rootDeps: ALIGNED, + webDeps: ALIGNED, + rawWebLock: { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /not in a readable format/); + }, + ); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index de4cccf0..ab928835 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -209,6 +209,25 @@ export function topLevelLockVersions(lock) { return versions; } +/** + * Whether a parsed lockfile has the shape this guard can read: a + * `lockfileVersion` 2+ `packages` table, keyed by install path with `""` for + * the root project. + * + * This is checked rather than tolerated because the gate is deny-by-default and + * `topLevelLockVersions` returns an empty map for anything else. An unreadable + * lockfile would otherwise contribute no holders, and a real skew among the + * remaining installs would be reported as aligned — the gate failing *open*, + * which is the one way it must never fail (Copilot, #1962). A v1 lockfile + * (`dependencies` only, no `packages`) lands here too, correctly: this guard + * cannot read it, so it must say so rather than skip the install. + */ +export function hasReadableLockShape(lock) { + const packages = lock?.packages; + if (typeof packages !== "object" || packages === null) return false; + return Object.prototype.hasOwnProperty.call(packages, ""); +} + /** * Find candidate packages that resolve to more than one version across the * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per @@ -375,13 +394,42 @@ export function main() { } const dirs = installDirs(); - const installs = dirs.map((dir) => ({ + const locks = dirs.map((dir) => { + const file = path.join(repoRoot, dir, "package-lock.json"); + let lock; + try { + lock = JSON.parse(readFileSync(file, "utf8")); + } catch (cause) { + // Unparseable is the same failure as unreadable — say which file, rather + // than dying on a raw SyntaxError with no path in it. + throw new Error( + `verify:dep-lockstep — could not parse ${dir}/package-lock.json.`, + { cause }, + ); + } + return { dir, lock }; + }); + + // Refuse to compare against a lockfile whose shape we can't read, instead of + // treating it as an install that holds nothing — see `hasReadableLockShape`. + const unreadable = locks.filter(({ lock }) => !hasReadableLockShape(lock)); + if (unreadable.length > 0) { + console.error( + `verify:dep-lockstep — ${unreadable.length} lockfile(s) are not in a readable format:\n`, + ); + for (const { dir } of unreadable) + console.error(` ${dir}/package-lock.json`); + console.error( + "\nThis guard reads the `packages` table of a lockfileVersion 2+ lockfile. Without it the install" + + "\ncontributes no versions, so a real skew among the others would be reported as aligned — the gate" + + "\nfailing open. Regenerate the lockfile with a current npm (`npm install`).", + ); + process.exit(1); + } + + const installs = locks.map(({ dir, lock }) => ({ dir, - versions: topLevelLockVersions( - JSON.parse( - readFileSync(path.join(repoRoot, dir, "package-lock.json"), "utf8"), - ), - ), + versions: topLevelLockVersions(lock), })); const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 89268ee4..79974404 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -8,6 +8,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { findSkew, + hasReadableLockShape, importedPackageNames, isSharedSourceFile, majorOf, @@ -243,10 +244,38 @@ test("topLevelLockVersions: nested duplicates are ignored", () => { }); test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { + // Safe as a pure helper *because* `hasReadableLockShape` rejects these before + // any comparison — an empty map reaching `findSkew` is the fail-open path. for (const lock of [undefined, null, {}, { packages: {} }]) assert.equal(topLevelLockVersions(lock).size, 0); }); +test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => { + assert.equal( + hasReadableLockShape({ lockfileVersion: 3, packages: { "": {} } }), + true, + ); + assert.equal( + hasReadableLockShape({ + lockfileVersion: 2, + packages: { "": {}, "node_modules/zod": { version: "4.4.3" } }, + }), + true, + ); + const rejected = [ + undefined, + null, + {}, + { packages: null }, + { packages: [] }, // an array has no `""` key + { packages: {} }, // no root entry + { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + ]; + for (const lock of rejected) + assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); +}); + test("findSkew: reports a package held at two versions", () => { const installs = [ { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, From 87c1e0542046d9376d08f8d3300ba1a2b76b9d30 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:09:42 -0400 Subject: [PATCH 064/941] chore(deps): count triple-slash type references as candidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's twelfth pass of #1962. A `/// <reference types="x" />` pulls in a package's declarations exactly like an import does, but TypeScript reports it in `preProcessFile`'s `typeReferenceDirectives`, not `importedFiles` — and only the latter was read. A shared source using one could therefore load the root's copy of a package alongside a client's without it ever entering the candidate set, so its skew would pass. Same silent-miss direction as the earlier syntax gaps, through a channel the parser switch didn't cover because I only read one of its two outputs. `typeReferencePackageNames` maps a directive to both candidates, since the name is the *type* name and not the package: `node` resolves to `@types/node`, while a package shipping its own declarations resolves to itself. Returning both over-approximates, the safe direction — whichever isn't installed drops out downstream. Scoped names mangle `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. `path` references name a file, not a package, and are ignored. The real candidate set is unchanged at 17: no shared source uses a triple-slash reference today. As with the other syntax fixes, this closes the channel against the one that arrives later. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 30 +++++++++++++++++++++-- scripts/verify-dep-lockstep.test.mjs | 36 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index ab928835..22ebb9ad 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -175,7 +175,30 @@ function typescript() { } /** - * Every third-party package name imported by a blob of TypeScript source. + * The package(s) a `/// <reference types="x" />` directive can resolve to + * (Copilot, #1962). Such a directive pulls in declarations exactly like an + * import does, but TypeScript reports it separately from `importedFiles`, so + * reading only the latter would let a referenced package skew unseen. + * + * Both candidates are returned because the directive name is the *type* name, + * not the package: `node` resolves to `@types/node`, while a package shipping + * its own declarations resolves to itself. Returning both over-approximates, + * which is the safe direction — whichever isn't installed drops out. A scoped + * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. + */ +export function typeReferencePackageNames(directive) { + const name = packageNameOf(directive); + if (!name) return []; + const scoped = /^@([^/]+)\/(.+)$/.exec(name); + const typesName = scoped + ? `@types/${scoped[1]}__${scoped[2]}` + : `@types/${name}`; + return [name, typesName]; +} + +/** + * Every third-party package name whose declarations a blob of TypeScript source + * pulls in — via an import of any form, or a triple-slash type reference. * Over-approximating is safe (a name absent from every lockfile contributes * nothing downstream — `@inspector/core` is a build-time alias, not a package, * and drops out that way); under-approximating is not, since a missed package @@ -185,11 +208,14 @@ export function importedPackageNames(source) { const names = new Set(); // (source, readImportFiles, detectJavaScriptImports) — the latter two make it // report `require(…)` and dynamic imports as well as static ones. - const { importedFiles } = typescript().preProcessFile(source, true, true); + const { importedFiles, typeReferenceDirectives } = + typescript().preProcessFile(source, true, true); for (const { fileName } of importedFiles) { const name = packageNameOf(fileName); if (name) names.add(name); } + for (const { fileName } of typeReferenceDirectives ?? []) + for (const name of typeReferencePackageNames(fileName)) names.add(name); return names; } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 79974404..a00ce642 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -16,6 +16,7 @@ import { partitionSkew, sourcesWithNoFiles, topLevelLockVersions, + typeReferencePackageNames, } from "./verify-dep-lockstep.mjs"; test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { @@ -196,6 +197,41 @@ test("importedPackageNames: static, side-effect, and dynamic forms; builtins and ]); }); +test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => { + // A `/// <reference types="x" />` pulls in declarations exactly like an + // import, but TypeScript reports it in `typeReferenceDirectives`, not + // `importedFiles` — so reading only the latter let a referenced package skew + // unseen. `path` references name a file, not a package, and are ignored. + const source = [ + '/// <reference types="node" />', + '/// <reference types="express" />', + '/// <reference path="./local.d.ts" />', + 'import { z } from "zod";', + ].join("\n"); + assert.deepEqual([...importedPackageNames(source)].sort(), [ + "@types/express", + "@types/node", + "express", + "node", + "zod", + ]); +}); + +test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => { + // The directive names a *type*, not a package: `node` resolves to + // `@types/node`, while a package shipping its own declarations resolves to + // itself. Returning both over-approximates, the safe direction — whichever + // isn't installed drops out downstream. + assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]); + // Scoped names mangle with a double underscore, TypeScript's convention. + assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [ + "@scope/pkg", + "@types/scope__pkg", + ]); + assert.deepEqual(typeReferencePackageNames("./relative"), []); + assert.deepEqual(typeReferencePackageNames(""), []); +}); + test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { // The regex scan this replaced could not tell code from a comment, so // `// adapted from "react"` added `react` to the candidate set — and if that From 8a2035c4d1145801914671b48f85d25bebe4a7bf Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:16:29 -0400 Subject: [PATCH 065/941] test(scripts): cover verify-format-coverage's sibling-guard vouch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's thirteenth pass of #1962. This PR added `verify:dep-lockstep` to the vouch loop in `verify-format-coverage.mjs` without a test for it. The existing tests exercise `rootReachesScript` in isolation and the opposite direction (dep-lockstep vouching for format-coverage), so a typo in a sibling's name here would have left `test:scripts` green while that guard silently stopped being enforced — the same "a gate that stops gating" failure the vouch cycle exists to prevent, one level up. Three cases, reusing the fixture-repo approach: dropping either sibling from `validate` exits 1 naming that sibling, and with both wired no sibling is reported missing. The last asserts on the *reason* rather than the exit status, since the fixture has no client manifests to harvest globs from and so fails later for an unrelated reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-format-coverage.main.test.mjs | 97 ++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 scripts/verify-format-coverage.main.test.mjs diff --git a/scripts/verify-format-coverage.main.test.mjs b/scripts/verify-format-coverage.main.test.mjs new file mode 100644 index 00000000..e10130f6 --- /dev/null +++ b/scripts/verify-format-coverage.main.test.mjs @@ -0,0 +1,97 @@ +// Regression tests for `verify-format-coverage`'s sibling-guard vouch (Copilot, +// #1962). The three root guards form a cycle so that dropping any one from +// `validate` is caught by another — but the vouch branch itself had no test, so +// a typo in a sibling's name would leave `test:scripts` green while that guard +// silently stopped being enforced. That is the same "a gate that stops gating" +// failure the cycle exists to prevent, one level up. +// +// The vouch runs before any file enumeration, so the fixture needs only a +// `package.json` and the script. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + +/** A `validate` chain running each named guard, plus the always-present self. */ +function scriptsRunning(guards) { + const scripts = { + validate: ["verify:format-coverage", ...guards] + .map((g) => `npm run ${g}`) + .join(" && "), + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + }; + for (const g of guards) scripts[g] = `node scripts/${g.slice(7)}.mjs`; + return scripts; +} + +/** + * Run `verify-format-coverage` in a throwaway repo whose root `validate` runs + * exactly `guards`. realpath'd because the script only executes when + * `import.meta.url` matches `process.argv[1]`, and macOS `tmpdir()` is a + * symlink — see the note in `verify-dep-lockstep.main.test.mjs`. + */ +function runWithGuards(guards) { + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "format-cov-"))); + try { + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { name: "fixture", scripts: scriptsRunning(guards) }, + null, + 2, + ), + ); + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-format-coverage.mjs", + path.join("lib", "npm-scripts.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-format-coverage.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + return { status: r.status, out: `${r.stdout}${r.stderr}` }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const BOTH_SIBLINGS = ["verify:typecheck-coverage", "verify:dep-lockstep"]; + +test("vouch: fails when `verify:dep-lockstep` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:typecheck-coverage"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:dep-lockstep`/); +}); + +test("vouch: fails when `verify:typecheck-coverage` is dropped from validate", () => { + const { status, out } = runWithGuards(["verify:dep-lockstep"]); + assert.equal(status, 1, out); + assert.match(out, /no longer runs `verify:typecheck-coverage`/); +}); + +test("vouch: passes when both siblings are still wired", () => { + // The run still fails afterwards — the fixture has no client manifests to + // harvest globs from — so assert on the *reason*, not the exit status: no + // sibling may be reported missing. + const { out } = runWithGuards(BOTH_SIBLINGS); + assert.doesNotMatch(out, /no longer runs/); +}); From e1768e44cebba8c3ca41a0e589e8a70af7c2f6f8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:23:32 -0400 Subject: [PATCH 066/941] chore(deps): check the declared lockfileVersion, not just the packages key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fourteenth pass of #1962. `hasReadableLockShape` and the diagnostic it drives both promise "lockfileVersion 2+", but the check only looked for a `packages` table with a root entry. A file declaring v1 while carrying such a table was accepted, produced an empty version map, and could make a real skew read as aligned once only one other holder remained — the same fail-open the shape check was added to close, through the half of the contract that wasn't enforced. The declared version is now verified: a finite number, 2 or greater. Absent, non-numeric, and declared-v1-with-packages cases are all in the table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.mjs | 9 ++++++++- scripts/verify-dep-lockstep.test.mjs | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 22ebb9ad..55553375 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -249,7 +249,14 @@ export function topLevelLockVersions(lock) { * cannot read it, so it must say so rather than skip the install. */ export function hasReadableLockShape(lock) { - const packages = lock?.packages; + // The declared version is checked, not just inferred from the presence of a + // `packages` key: this function and the diagnostic it drives both promise + // "lockfileVersion 2+", so a file claiming v1 while carrying a `packages` + // table must be rejected rather than half-trusted (Copilot, #1962). + const version = lock?.lockfileVersion; + if (typeof version !== "number" || !Number.isFinite(version) || version < 2) + return false; + const packages = lock.packages; if (typeof packages !== "object" || packages === null) return false; return Object.prototype.hasOwnProperty.call(packages, ""); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index a00ce642..55ca3b6f 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -302,11 +302,20 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot undefined, null, {}, - { packages: null }, - { packages: [] }, // an array has no `""` key - { packages: {} }, // no root entry - { packages: { "node_modules/zod": { version: "4.4.3" } } }, + { lockfileVersion: 3, packages: null }, + { lockfileVersion: 3, packages: [] }, // an array has no `""` key + { lockfileVersion: 3, packages: {} }, // no root entry + { + lockfileVersion: 3, + packages: { "node_modules/zod": { version: "4.4.3" } }, + }, { lockfileVersion: 1, dependencies: { zod: { version: "4.4.3" } } }, + // A declared v1 carrying a `packages` table: the version is checked, not + // inferred from the key's presence, so this is rejected rather than + // half-trusted into an empty (fail-open) version map. + { lockfileVersion: 1, packages: { "": {} } }, + { packages: { "": {} } }, // no declared version at all + { lockfileVersion: "3", packages: { "": {} } }, // not a number ]; for (const lock of rejected) assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); From 288adbcfd621a53edc0115653a8d81836a24c750 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 22:31:05 -0400 Subject: [PATCH 067/941] chore(deps): enrol installs by package.json, and fail on a missing lockfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From Copilot's fifteenth pass of #1962. `installDirs` filtered on the presence of a lockfile, so a missing one silently removed that install from the comparison rather than failing. For the root — the install every shared source resolves from — that meant the guard could report success from client locks alone, which is the deny-by-default contract inverted. Enrolment is now by `package.json` (an install we are meant to compare), with the root always enrolled, and a missing lockfile is a named, loud failure. A `clients/` directory with no `package.json` is not an install and is skipped, so a stray scratch dir isn't asked for a lockfile it should never have. Three regression cases: a missing client lockfile in a fixture that IS skewed (so failing open would show as a pass), a missing ROOT lockfile, and a stray `clients/` dir that must not be enrolled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- scripts/verify-dep-lockstep.main.test.mjs | 41 +++++++++++++++++++++++ scripts/verify-dep-lockstep.mjs | 34 +++++++++++++++++-- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index 50db6b99..c00379d7 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -228,6 +228,47 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () ); }); +test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", () => { + // Enrolment is by `package.json`, so a missing lockfile is loud rather than a + // silently absent row. The fixture IS skewed, so dropping the install instead + // would leave the remaining holder unopposed and report aligned. + withFixture( + { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }, + ); +}); + +test("main: exits 1 when the ROOT lockfile is missing", () => { + // The worst variant: the root is the install every shared source resolves + // from, so omitting it could let the guard pass on client locks alone. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + rmSync(path.join(dir, "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /^\s+\.\/package-lock\.json$/m); + }); +}); + +test("main: a clients/ dir with no package.json is not an install", () => { + // Enrolling a stray directory would demand a lockfile it should never have. + withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); + writeFileSync( + path.join(dir, "clients", "scratch", "notes.md"), + "scratch\n", + ); + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + }); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 55553375..27b5812c 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -377,9 +377,18 @@ function installDirs() { .map((e) => `clients/${e.name}`) .sort() : []; - return ["."] - .concat(clients) - .filter((dir) => existsSync(path.join(repoRoot, dir, "package-lock.json"))); + // Enrolment is by `package.json` — an install we are meant to compare — + // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the + // lockfile made a missing one silently drop that install from the + // comparison; for the root, the install every shared source resolves from, + // that meant the guard could report success from client locks alone. A + // missing lockfile is now a loud failure in `main`, not an absent row. The + // root is always enrolled: it is this repo, so its manifest is a given. + return ["."].concat( + clients.filter((dir) => + existsSync(path.join(repoRoot, dir, "package.json")), + ), + ); } /** @@ -427,6 +436,25 @@ export function main() { } const dirs = installDirs(); + + // A missing lockfile is a failure, not a skipped install: dropping one would + // remove its versions from the comparison and could report a real skew as + // aligned. + const missing = dirs.filter( + (dir) => !existsSync(path.join(repoRoot, dir, "package-lock.json")), + ); + if (missing.length > 0) { + console.error( + `verify:dep-lockstep — ${missing.length} install(s) have a package.json but no lockfile:\n`, + ); + for (const dir of missing) console.error(` ${dir}/package-lock.json`); + console.error( + "\nEvery install must be compared; skipping one could report a real skew as aligned." + + "\nRun `npm install` there, or remove the install if it is no longer part of the repo.", + ); + process.exit(1); + } + const locks = dirs.map((dir) => { const file = path.join(repoRoot, dir, "package-lock.json"); let lock; From 719da6fbdd908187ad0e8c6df3d29406bdb3d590 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:21:15 -0400 Subject: [PATCH 068/941] ci: gate claude.yml on same-repo PRs and pin actions to commit SHAs (#1882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of hardening on the Claude Code workflow, plus a comment for a finding that needed no change. Same-repo gating. `Get PR details` now emits an `is_fork` output, and the PR-head checkout and the `Run Claude Code` step are both gated on it. A fork PR is declined outright — no checkout, no agent run, with the reason written to the step summary — rather than falling through to a "metadata-only" review of the base tree, which would trade an untrusted-code problem for a wrong-tree one. A deleted fork (`head.repo` null) counts as a fork. The head checkout also drops its `repository:` input: only a same-repo head reaches that step now, and checkout's default `github.repository` is a value no PR can influence. SHA pins. actions/checkout, actions/github-script, and claude-code-action were all on mutable major tags in a job that holds ANTHROPIC_API_KEY and grants the agent Bash. Each is now pinned to a full commit SHA with the trailing `# vX.Y.Z` comment Dependabot reads, so upgrade automation is unaffected. Pinned to the v7 / v9 SHAs so this does not regress the bumps in the open Dependabot PR #1922. The failure fallback Copilot flagged turns out not to exist: `Checkout repository` carries no status-check function, so GitHub applies an implicit `success()` and skips it after a failed lookup regardless of the `outcome` comparison. Its condition is now the explicit `== 'skipped'` and says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .github/workflows/claude.yml | 55 ++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 8aa4ba71..f68ae527 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -35,13 +35,18 @@ jobs: id-token: write actions: read steps: + # Actions are pinned to full commit SHAs rather than mutable major tags: + # this job holds ANTHROPIC_API_KEY and grants the agent Bash, so a + # force-moved tag would be an unreviewed code change inside a + # secret-holding job. The trailing `# vX.Y.Z` comment is the form + # Dependabot reads, so pinning costs us no upgrade automation. (#1882) - name: Get PR details if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request) || github.event_name == 'pull_request_review_comment' || github.event_name == 'pull_request_review' id: pr - uses: actions/github-script@v8 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | let prNumber; @@ -57,26 +62,58 @@ jobs: pull_number: prNumber }); - core.setOutput('sha', pr.data.head.sha); - core.setOutput('repo', pr.data.head.repo.full_name); + // A fork PR's head lives in a different repository — or in none at + // all, if the fork was deleted after the PR was opened (`head.repo` + // is then null). Neither is ours to check out, so both count as a + // fork here and the steps below decline rather than guess. + const headRepo = pr.data.head.repo?.full_name ?? null; + const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`; + core.setOutput('sha', pr.data.head.sha); + core.setOutput('is_fork', String(isFork)); + + # A fork PR's head is untrusted code, and checking it out would put it in + # reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing + # the base tree instead would only trade that for a confident review of + # the wrong tree, so decline visibly and leave the reason in the run. + - name: Decline fork PR + if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true' + run: | + { + echo "### Claude Code declined this pull request" + echo + echo "The head branch lives in a fork, so its code is not checked out and Claude is not run." + echo "Push the branch to this repository and re-trigger if a review is needed." + } >> "$GITHUB_STEP_SUMMARY" + + # No `repository:` — a same-repo head is all that reaches this step, and + # checkout defaults to `github.repository`, which no PR can influence. - name: Checkout PR branch - if: steps.pr.outcome == 'success' - uses: actions/checkout@v6 + if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ steps.pr.outputs.sha }} - repository: ${{ steps.pr.outputs.repo }} fetch-depth: 0 + # `skipped` means the trigger was an issue or a non-PR comment, so there + # is no head to check out and the base tree is the right one. The lookup + # having *failed* is deliberately not included: this condition carries no + # status-check function, so GitHub applies an implicit `success()` and + # skips the step after a failed prior step anyway. Spelling out `skipped` + # keeps that from having to be re-derived by the next reader. - name: Checkout repository - if: steps.pr.outcome != 'success' - uses: actions/checkout@v6 + if: steps.pr.outcome == 'skipped' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run Claude Code + # Runs only against a tree this workflow actually checked out: the base + # repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here + # with `is_fork == 'true'` and both disjuncts false, so it is skipped. + if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false' id: claude - uses: anthropics/claude-code-action@v1 + uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} From 0325d81493eda089afc40af089b3d8028859dfd9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:25:33 -0400 Subject: [PATCH 069/941] fix: open the modern listen stream whenever the filter is non-empty (#1920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the modern (2026-07-28) era every server→client notification rides the `subscriptions/listen` stream, but the Inspector only ever opened that stream from a resource-subscribe click. A tools-only server advertising `tools.listChanged` therefore had no path to an open stream, so `notifications/tools/list_changed` could never arrive — even though `buildSubscriptionFilter()` already modelled the opt-in. The trigger now matches the filter it builds: the stream opens (and stays open, and reconnects) whenever the built filter carries anything — subscribed URIs or an enabled list-change opt-in the server advertises. That mirrors the SDK's own `ClientOptions.listChanged` auto-open, which opens on a non-empty config ∩ capability intersection. `connect()` opens it once the handlers are registered; a failure there is handed to the reconnect machinery rather than failing the connect. `ResourceSubscriptionStreamState.active` deliberately keeps its narrower meaning — "at least one URI is subscribed" — because it drives the Subscriptions section's badge, which has nothing to report for a listChanged-only stream. That also preserves the invariant the rest of the file is written against: an empty subscribed set is never announced alongside an `active` stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../ResourceControls/ResourceControls.tsx | 4 +- .../inspectorClient-subscriptions-era.test.ts | 218 +++++++++++++++++- core/mcp/inspectorClient.ts | 116 ++++++++-- core/mcp/types.ts | 11 +- 4 files changed, 321 insertions(+), 28 deletions(-) diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index 8d4e9590..adf5aa1a 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -52,7 +52,9 @@ export interface ResourceControlsProps { * Modern-era `subscriptions/listen` stream state (#1630). When `active` * (modern era with at least one subscription) the Subscriptions section shows * a stream-status badge in its panel and a status dot in its header. Legacy - * connections pass `active: false` (or omit it) and see neither. + * connections pass `active: false` (or omit it) and see neither — and so does + * a stream open purely for list-change notifications, which this section has + * nothing to say about (#1920). */ subscriptionStreamState?: ResourceSubscriptionStreamState; /** Negotiated protocol era; gates the modern subscription stream chrome. */ diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index d10610a2..6d182817 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -8,6 +8,7 @@ import { type TestServerHttp, createTestServerInfo, createNumberedResources, + createEchoTool, } from "@modelcontextprotocol/inspector-test-server"; import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; @@ -23,6 +24,13 @@ describe("resource subscriptions era fork (#1630)", () => { let server: TestServerHttp | null = null; const RESOURCE_URI = "test://resource_0"; + /** + * Every list-change opt-in off. The listen filter is config ∩ capability and + * the SDK server advertises `listChanged` for every list it registers, so this + * is what leaves the filter empty when nothing is subscribed — i.e. the + * pre-#1920 world these lifecycle tests were written against. + */ + const NO_LIST_CHANGED = { tools: false, resources: false, prompts: false }; const RESOURCE_URI_2 = "test://resource_1"; afterEach(async () => { @@ -59,15 +67,28 @@ describe("resource subscriptions era fork (#1630)", () => { return started; } + /** + * @param listChangedNotifications the Inspector-side opt-ins. The SDK server + * advertises `listChanged` for every list it registers, so this — not the + * server config — is how a test gets an *empty* listen filter: the filter is + * config ∩ capability, and with every opt-in off only a subscribed URI can + * open the stream (#1920). + */ async function connect( url: string, era: "legacy" | "modern", + listChangedNotifications?: { + tools?: boolean; + resources?: boolean; + prompts?: boolean; + }, ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> { const connected = new InspectorClient( { type: "streamable-http", url }, { environment: { transport: createTransportNode }, versionNegotiation: eraToVersionNegotiation(era), + ...(listChangedNotifications ? { listChangedNotifications } : {}), }, ); const messages: MessageEntry[] = []; @@ -125,15 +146,43 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("closes the stream when the last subscription is removed", async () => { + // Nothing else in the filter (no list-change opt-ins), so the last URI + // leaving empties it and the stream closes (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected, messages } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); expect(connected.getResourceSubscriptionStreamState().active).toBe(true); + messages.length = 0; await connected.unsubscribeFromResource(RESOURCE_URI); const streamState = connected.getResourceSubscriptionStreamState(); expect(streamState.active).toBe(false); expect(connected.getSubscribedResources()).toEqual([]); + // No re-listen: there is nothing left to listen for. + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("keeps the stream open past the last subscription when a listChanged opt-in remains", async () => { + // The other half of the above: with an advertised listChanged the filter + // is still non-empty, so the last unsubscribe re-lists rather than + // closing — and the state goes inactive because the *Subscriptions* + // section has nothing to report, not because the stream is gone (#1920). + const started = await startServer({}); + const { connected, messages } = await connect(started.url, "modern"); + await connected.subscribeToResource(RESOURCE_URI); + + messages.length = 0; + await connected.unsubscribeFromResource(RESOURCE_URI); + + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + const filter = lastListenFilter(messages); + expect(filter?.resourcesListChanged).toBe(true); + expect(filter?.resourceSubscriptions).toBeUndefined(); }); it("re-lists (stream stays open) when one of several URIs is removed", async () => { @@ -197,6 +246,145 @@ describe("resource subscriptions era fork (#1630)", () => { }); }); + // The stream used to be reachable only by subscribing to a resource, so a + // server with no resources could never open it — and its `tools.listChanged` + // notifications, which ride the same stream, could never arrive (#1920). + describe("modern era: opening the stream for listChanged alone (#1920)", () => { + async function startToolsOnlyServer( + listChanged: ServerConfig["listChanged"], + ): Promise<TestServerHttp> { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-test", "1.0.0"), + tools: [createEchoTool()], + listChanged, + modern: {}, + }); + await started.start(); + server = started; + return started; + } + + it("opens the stream on connect against a tools-only server", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected, messages } = await connect(started.url, "modern"); + + expect(connected.getCapabilities()?.resources).toBeUndefined(); + expect(methodsSent(messages)).toContain("subscriptions/listen"); + const filter = lastListenFilter(messages); + expect(filter?.toolsListChanged).toBe(true); + // Nothing is subscribed, so the filter carries no URIs at all. + expect(filter?.resourceSubscriptions).toBeUndefined(); + // …and the Subscriptions section still has nothing to report. + expect(connected.getSubscribedResources()).toEqual([]); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("leaves the stream closed when the opt-in is disabled in config", async () => { + // The filter is config ∩ capability, so turning the handler off in the + // Inspector's own options is enough to keep the stream closed. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + listChangedNotifications: { tools: false }, + }, + ); + const messages: MessageEntry[] = []; + connected.addEventListener("message", (event) => { + messages.push(event.detail); + }); + await connected.connect(); + client = connected; + + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + }); + + it("opens no stream on the legacy era", async () => { + // Legacy has no `subscriptions/listen` at all — list-change notifications + // arrive on the session's own channel. + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("tools-only-legacy-test", "1.0.0"), + tools: [createEchoTool()], + listChanged: { tools: true }, + }); + await started.start(); + server = started; + const { connected, messages } = await connect(started.url, "legacy"); + + expect(connected.getProtocolEra()).toBe("legacy"); + expect(methodsSent(messages)).not.toContain("subscriptions/listen"); + expect(connected.getResourceSubscriptionStreamState().active).toBe(false); + }); + + it("reconnects a dropped listChanged-only stream (no subscriptions to keep it alive)", async () => { + const started = await startToolsOnlyServer({ tools: true }); + const { connected } = await connect(started.url, "modern"); + + const int = connected as unknown as { + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + }; + const dropped = int.modernSubscription; + expect(dropped).not.toBeNull(); + if (!dropped) return; + + int.onModernSubscriptionClosed( + dropped, + "remote", + int.modernListenGeneration, + ); + // Reconnect-by-re-listen runs on the filter, not on the subscribed set — + // which is empty here, and used to be the reason to give up. + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + await vi.waitFor(() => { + expect(int.modernSubscription).not.toBeNull(); + }); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "acknowledged", + ); + }); + + it("connects anyway when the connect-time listen fails, and retries", async () => { + // The handshake succeeded; every request-scoped feature works without the + // stream, so the failure is handed to the reconnect machinery instead of + // failing `connect()`. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + // Shadow the private refresh on the instance so the connect-time open + // fails without a live client to reach into (there is none until + // `connect()` builds one). + ( + connected as unknown as { + refreshModernSubscription: () => Promise<void>; + } + ).refreshModernSubscription = () => + Promise.reject(new Error("listen boom")); + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + expect(connected.getStatus()).toBe("connected"); + expect(connected.getResourceSubscriptionStreamState().status).toBe( + "reconnecting", + ); + }); + }); + describe("legacy era", () => { it("subscribes via resources/subscribe with no listen stream", async () => { const started = await startServer(undefined); @@ -355,8 +543,17 @@ describe("resource subscriptions era fork (#1630)", () => { // Reachable without any close() failure: a rejecting `listen()` does it, // which is what a user subscribing while the reconnect timer re-lists // (i.e. exactly when the server is flaky) can produce. + // + // No list-change opt-ins, so connect leaves the stream closed (#1920) and + // the first subscribe's `listen()` is reached synchronously — with a + // stream to tear down first, the swap below would land after this call + // already read `client.listen`. const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); const real = int.client.listen; @@ -504,8 +701,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("reflects the subscription optimistically as 'connecting' before the ack", async () => { + // No list-change opt-ins → no connect-time stream, so the subscribe's + // `listen()` is the first one and this test's held ack is the one it waits + // on (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); const int = internals(connected); // Hold the listen ack so we can observe the pre-ack (optimistic) state. @@ -632,8 +836,14 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("does not reconnect when the subscription set empties before the timer fires", async () => { + // No list-change opt-ins, so emptying the set empties the *filter* — + // which is what the timer's bail now tests (#1920). const started = await startServer({}); - const { connected } = await connect(started.url, "modern"); + const { connected } = await connect( + started.url, + "modern", + NO_LIST_CHANGED, + ); await connected.subscribeToResource(RESOURCE_URI); const int = internals(connected); const fake = await installFakeSubscription(int); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 8e268477..31a7e5e3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -479,7 +479,10 @@ export class InspectorClient extends InspectorClientEventTarget { // `McpSubscription` whose filter's `resourceSubscriptions` mirrors // `subscribedResources`; mutating the set re-lists (close old, open new), and // an unexpected `"remote"` close re-lists (reconnect-by-re-listen — the stream - // is not resumable). `null` when no URI is subscribed or on the legacy era. + // is not resumable). `null` on the legacy era, and on the modern era whenever + // the filter is empty — which is *not* the same as "no URI subscribed": the + // stream also carries the list-change opt-ins, so a tools-only server opens it + // with no subscriptions at all (#1920). private modernSubscription: McpSubscription | null = null; // Monotonic guard so a stale re-list/reconnect (whose `listen()` or `closed` // resolves after a newer refresh already started) can detect it lost the race @@ -2056,6 +2059,14 @@ export class InspectorClient extends InspectorClientEventTarget { // a progress notification handler so the Protocol's _onprogress stays; timeout reset // and routing work, and we inject the caller's progressToken into dispatched events. } + + // Modern era: the handlers registered above only fire for notifications + // that reach us, and on this era every server→client notification rides + // the `subscriptions/listen` stream. Open it now when the filter says + // there is something to listen for — otherwise a list-change opt-in on a + // server with no resources would have no way in, since a subscribe click + // was the only thing that ever opened the stream (#1920). + await this.openModernListenStreamOnConnect(); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; @@ -4893,9 +4904,13 @@ export class InspectorClient extends InspectorClientEventTarget { * opted-in notification type (SEP §7.4). */ private buildSubscriptionFilter(): SubscriptionFilter { - const filter: SubscriptionFilter = { - resourceSubscriptions: Array.from(this.subscribedResources), - }; + const filter: SubscriptionFilter = {}; + // Omitted rather than sent empty: a listChanged-only stream (#1920) is not + // subscribing to any resource, and `[]` would say it asked for none of a set + // it is participating in. + if (this.subscribedResources.size > 0) { + filter.resourceSubscriptions = Array.from(this.subscribedResources); + } if ( this.listChangedNotifications.tools && this.capabilities?.tools?.listChanged @@ -4917,6 +4932,69 @@ export class InspectorClient extends InspectorClientEventTarget { return filter; } + /** + * Whether the modern listen stream should be open: the built filter carries + * something to listen for (#1920). Before #1920 this was "at least one URI is + * subscribed", which made the stream unreachable on a server with no resources + * — a tools-only server advertising `tools.listChanged` had no way to open it, + * so `notifications/tools/list_changed` could never arrive. The filter already + * modelled the list-change opt-ins; only the trigger was narrower than the + * filter it built. This matches the SDK's own `ClientOptions.listChanged` + * auto-open, which opens whenever the effective (config ∩ capability) + * intersection is non-empty. + * + * Note this is deliberately *not* the same predicate as the stream state's + * `active` — see `modernStreamActive()`. + */ + private wantsModernStream(): boolean { + const filter = this.buildSubscriptionFilter(); + return ( + (filter.resourceSubscriptions?.length ?? 0) > 0 || + filter.toolsListChanged === true || + filter.resourcesListChanged === true || + filter.promptsListChanged === true + ); + } + + /** + * Whether the stream state reports `active` — i.e. whether the *Subscriptions* + * UI has a stream to describe. That is a narrower question than + * `wantsModernStream()`: `ResourceSubscriptionStreamState` drives the + * Subscriptions section's badge, so a stream open purely for list-change + * notifications (no subscribed URI) has nothing to report there and stays + * `active: false` (#1920). Keeping the two apart also preserves the invariant + * the rest of this file is written against — an empty subscribed set is never + * announced alongside an `active` stream. + */ + private modernStreamActive(): boolean { + return this.subscribedResources.size > 0; + } + + /** + * Open the modern listen stream at the end of a successful connect, when the + * filter is non-empty (#1920). Only the list-change opt-ins can make it + * non-empty here — the subscribed set is emptied by `resetSessionState()` on + * the way in — so this is exactly the "server advertises a listChanged the + * Inspector wants" case that had no trigger before. + * + * A failure is not allowed to fail the connect: the handshake succeeded and + * every request-scoped feature works without this stream. It is reported the + * way a lost stream is — hand it to the reconnect machinery, which retries + * with backoff and settles on `"ended"` past the cap. + */ + private async openModernListenStreamOnConnect(): Promise<void> { + if (!this.isModernEra() || !this.wantsModernStream()) return; + try { + await this.refreshModernSubscription(); + } catch (error) { + this.logger.error( + { error }, + "Failed to open the modern subscriptions/listen stream on connect", + ); + this.reconcileModernStreamStateAfterFailedRefresh(); + } + } + /** Cancel a pending reconnect re-listen, if any (#1630). */ private clearModernReconnectTimer(): void { if (this.modernReconnectTimer !== undefined) { @@ -4927,9 +5005,10 @@ export class InspectorClient extends InspectorClientEventTarget { /** * (Re-)establish the modern `subscriptions/listen` stream to match the current - * `subscribedResources` set (#1630). Because the stream is not resumable, - * every filter change re-lists: the existing stream is closed and a fresh - * `listen()` opened. With no subscribed URIs the stream is left closed. + * filter (#1630). Because the stream is not resumable, every filter change + * re-lists: the existing stream is closed and a fresh `listen()` opened. With + * an empty filter — no subscribed URIs *and* no enabled list-change opt-in the + * server advertises — the stream is left closed (#1920). * * `modernListenGeneration` guards against races — if a newer refresh starts * while this one awaits its acknowledgement, the just-opened stream is @@ -4955,8 +5034,8 @@ export class InspectorClient extends InspectorClientEventTarget { await closeSubscriptionBestEffort(previous); } - // Nothing subscribed → keep the stream closed. - if (this.subscribedResources.size === 0) { + // Nothing to listen for → keep the stream closed. + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -4978,7 +5057,7 @@ export class InspectorClient extends InspectorClientEventTarget { // starts fresh next time (#1630). this.modernReconnectAttempts = 0; this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "acknowledged", honoredUris: subscription.honoredFilter.resourceSubscriptions ?? [], }); @@ -5024,7 +5103,7 @@ export class InspectorClient extends InspectorClientEventTarget { const shouldReconnect = reason === "remote" && !isTerminalStatus(this.status) && - this.subscribedResources.size > 0; + this.wantsModernStream(); if (!shouldReconnect) { // "stream gone but subscriptions remain" renders the same whether we gave // up after failed reconnects or the server closed it gracefully: keep the @@ -5033,7 +5112,7 @@ export class InspectorClient extends InspectorClientEventTarget { // until the next `connect()` calls `resetSubscriptionStream`, which moves // them together — so "active with an empty set" is never observable.) this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); @@ -5073,7 +5152,7 @@ export class InspectorClient extends InspectorClientEventTarget { * its error either way — the retry is about the subscriptions, not the call. */ private reconcileModernStreamStateAfterFailedRefresh(): void { - if (this.subscribedResources.size === 0) { + if (!this.wantsModernStream()) { this.setModernStreamState(INACTIVE_SUBSCRIPTION_STREAM_STATE); return; } @@ -5088,7 +5167,7 @@ export class InspectorClient extends InspectorClientEventTarget { */ private scheduleModernReconnect(): void { this.setModernStreamState({ - active: true, + active: this.modernStreamActive(), status: "reconnecting", honoredUris: [], }); @@ -5101,10 +5180,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.modernReconnectTimer = undefined; // Disconnect/unsubscribe may have raced the timer — bail if the reconnect // is no longer wanted. - if ( - isTerminalStatus(this.status) || - this.subscribedResources.size === 0 - ) { + if (isTerminalStatus(this.status) || !this.wantsModernStream()) { return; } this.refreshModernSubscription(true).catch(() => @@ -5123,10 +5199,10 @@ export class InspectorClient extends InspectorClientEventTarget { if ( this.modernReconnectAttempts > MODERN_RECONNECT_MAX_ATTEMPTS || isTerminalStatus(this.status) || - this.subscribedResources.size === 0 + !this.wantsModernStream() ) { this.setModernStreamState({ - active: this.subscribedResources.size > 0, + active: this.modernStreamActive(), status: "ended", honoredUris: [], }); diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 52840af8..b89f117b 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -436,9 +436,14 @@ export type ResourceSubscriptionStreamStatus = * persistent stream, so `active` is `false` and the UI surfaces no stream chrome. * On the modern era all subscriptions are a filter over one long-lived * `subscriptions/listen` stream; `active` is `true` whenever that stream is being - * managed (i.e. at least one URI is subscribed), and `honoredUris` is the subset - * of requested URIs the server acknowledged in its `honoredFilter` (may be a - * strict subset — a server is allowed to decline some). + * managed *for resource subscriptions* (i.e. at least one URI is subscribed), and + * `honoredUris` is the subset of requested URIs the server acknowledged in its + * `honoredFilter` (may be a strict subset — a server is allowed to decline some). + * + * `active: false` does not imply no stream: the same stream also carries the + * list-change opt-ins, so it can be open with no subscribed URI at all (#1920). + * This state describes the Subscriptions section, which has nothing to show for + * such a stream. */ export interface ResourceSubscriptionStreamState { active: boolean; From eda8ec0db8afdf0543567019c0771bdf0d3f66bd Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:34:19 -0400 Subject: [PATCH 070/941] fix: guard the connect-time listen failure on the refresh generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: `openModernListenStreamOnConnect`'s catch reconciled unconditionally, unlike the subscribe/unsubscribe call sites. The `connect` event is dispatched before the stream opens, so a listener subscribing to a resource can start and acknowledge a newer refresh while this one is still awaiting its `listen()` — and reconciling anyway would arm a reconnect that tears down a healthy stream. Apply the same generation test. Also consolidates the tests' private-member access onto the file's existing `internals()` helper (one justified double cast, hoisted and documented) and makes the failure stub bump the generation the way the real method does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 102 ++++++++++++------ core/mcp/inspectorClient.ts | 12 ++- 2 files changed, 80 insertions(+), 34 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 6d182817..91daa638 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -124,6 +124,34 @@ describe("resource subscriptions era fork (#1630)", () => { return params.notifications as Record<string, unknown> | undefined; } + /** + * The private members these tests drive directly. `InspectorClient` declares + * every one of them, so the shape below is a faithful mirror rather than a + * reinterpretation — the double cast is only to reach past `private`, which no + * single `as` can do (the two types share no public overlap). It is confined to + * `internals()` so there is one such cast in the file, and it is unavoidable: + * these are lifecycle branches no public API can reach against a healthy server + * (there is no way to ask for a *remote* stream close, or for a `listen()` that + * fails). + */ + interface StreamInternals { + client: { listen: (...args: unknown[]) => Promise<McpSubscription> }; + modernSubscription: McpSubscription | null; + modernListenGeneration: number; + modernReconnectAttempts: number; + subscribedResources: Set<string>; + refreshModernSubscription(fromReconnect?: boolean): Promise<void>; + onModernSubscriptionClosed( + subscription: McpSubscription, + reason: "local" | "graceful" | "remote", + generation: number, + ): void; + } + + function internals(c: InspectorClient): StreamInternals { + return c as unknown as StreamInternals; + } + describe("modern era", () => { it("opens an acknowledged listen stream on subscribe (no resources/subscribe)", async () => { const started = await startServer({}); @@ -322,15 +350,7 @@ describe("resource subscriptions era fork (#1630)", () => { const started = await startToolsOnlyServer({ tools: true }); const { connected } = await connect(started.url, "modern"); - const int = connected as unknown as { - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - }; + const int = internals(connected); const dropped = int.modernSubscription; expect(dropped).not.toBeNull(); if (!dropped) return; @@ -367,13 +387,15 @@ describe("resource subscriptions era fork (#1630)", () => { ); // Shadow the private refresh on the instance so the connect-time open // fails without a live client to reach into (there is none until - // `connect()` builds one). - ( - connected as unknown as { - refreshModernSubscription: () => Promise<void>; - } - ).refreshModernSubscription = () => - Promise.reject(new Error("listen boom")); + // `connect()` builds one). The bump mirrors the real method's contract — + // it claims the generation synchronously before it can fail — which is + // what the caller's guard reads to tell "our refresh failed" from "a newer + // one took over". + const int = internals(connected); + int.refreshModernSubscription = () => { + int.modernListenGeneration++; + return Promise.reject(new Error("listen boom")); + }; await expect(connected.connect()).resolves.toBeUndefined(); client = connected; @@ -383,6 +405,37 @@ describe("resource subscriptions era fork (#1630)", () => { "reconnecting", ); }); + + it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { + // `connect` is dispatched before the stream is opened, so a listener can + // start and acknowledge a newer refresh while this one is still awaiting + // its `listen()`. Reconciling anyway would arm a reconnect against a + // healthy stream and tear it down — the same ownership test the + // subscribe/unsubscribe paths make. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + const int = internals(connected); + // Two bumps: this call's own, plus the newer refresh that superseded it + // before its failure landed. + int.refreshModernSubscription = () => { + int.modernListenGeneration += 2; + return Promise.reject(new Error("listen boom")); + }; + + await expect(connected.connect()).resolves.toBeUndefined(); + client = connected; + + // No reconnect armed: the state is the newer refresh's to write. + expect(connected.getResourceSubscriptionStreamState().status).not.toBe( + "reconnecting", + ); + }); }); describe("legacy era", () => { @@ -433,23 +486,6 @@ describe("resource subscriptions era fork (#1630)", () => { // the client's private state — the pattern used across the InspectorClient // coverage-backfill suite — to drive them deterministically. describe("modern stream internals", () => { - interface StreamInternals { - client: { listen: (...args: unknown[]) => Promise<McpSubscription> }; - modernSubscription: McpSubscription | null; - modernListenGeneration: number; - modernReconnectAttempts: number; - subscribedResources: Set<string>; - onModernSubscriptionClosed( - subscription: McpSubscription, - reason: "local" | "graceful" | "remote", - generation: number, - ): void; - } - - function internals(c: InspectorClient): StreamInternals { - return c as unknown as StreamInternals; - } - /** A controllable fake `McpSubscription` whose `closed` we resolve on demand. */ function makeFakeSub(): { sub: McpSubscription; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 31a7e5e3..c9ec8207 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4981,9 +4981,17 @@ export class InspectorClient extends InspectorClientEventTarget { * every request-scoped feature works without this stream. It is reported the * way a lost stream is — hand it to the reconnect machinery, which retries * with backoff and settles on `"ended"` past the cap. + * + * Gated on the same generation test as `subscribeToResource` — see the long + * comment there. This call is *not* the only refresh that can be in flight: + * the `connect` event has already been dispatched, so a listener subscribing + * to a resource can start and acknowledge a newer refresh while this one is + * still awaiting its `listen()`. Reconciling unconditionally would then arm a + * reconnect that tears down a stream that is up and healthy. */ private async openModernListenStreamOnConnect(): Promise<void> { if (!this.isModernEra() || !this.wantsModernStream()) return; + const generationBefore = this.modernListenGeneration; try { await this.refreshModernSubscription(); } catch (error) { @@ -4991,7 +4999,9 @@ export class InspectorClient extends InspectorClientEventTarget { { error }, "Failed to open the modern subscriptions/listen stream on connect", ); - this.reconcileModernStreamStateAfterFailedRefresh(); + if (this.modernListenGeneration === generationBefore + 1) { + this.reconcileModernStreamStateAfterFailedRefresh(); + } } } From ca70a7cfa7d7860e7e4f89b9d2f51d2b5aa78edb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:46:32 -0400 Subject: [PATCH 071/941] chore(storybook): drop the redundant setProjectAnnotations setup file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since Storybook 10.3, @storybook/addon-vitest provisions the preview annotations itself — and *skips* doing so when it finds a setup file calling setProjectAnnotations. So .storybook/vitest.setup.ts was not merely redundant, it was actively opting the project out of the automatic path, which the addon printed a notice about on every run. Remove the file and the `setupFiles` entry from the `storybook` vitest project. A green suite doesn't prove the automatic provisioning works: without the preview annotations, stories would render outside MantineProvider and without App.css, and would very likely still pass. Add src/test/PreviewAnnotations.stories.tsx, which asserts from a play function that the Mantine theme variables and the App.css tokens are actually present in the rendered document, so an unthemed render fails loudly. Verified by emptying `decorators` in preview.tsx and confirming the story goes red. The a11y addon's axe runner can't be introspected from a play function, so it was checked out-of-band: a story with a real violation turned the suite red with the setup file already removed, confirming @storybook/addon-a11y/preview is applied automatically. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- clients/web/.storybook/vitest.setup.ts | 7 -- .../src/test/PreviewAnnotations.stories.tsx | 78 +++++++++++++++++++ clients/web/vite.config.ts | 10 ++- 3 files changed, 87 insertions(+), 8 deletions(-) delete mode 100644 clients/web/.storybook/vitest.setup.ts create mode 100644 clients/web/src/test/PreviewAnnotations.stories.tsx diff --git a/clients/web/.storybook/vitest.setup.ts b/clients/web/.storybook/vitest.setup.ts deleted file mode 100644 index fd7ac45e..00000000 --- a/clients/web/.storybook/vitest.setup.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; -import { setProjectAnnotations } from "@storybook/react-vite"; -import * as projectAnnotations from "./preview"; - -// This is an important step to apply the right configuration when testing your stories. -// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations -setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx new file mode 100644 index 00000000..50cafe5d --- /dev/null +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -0,0 +1,78 @@ +import { Button } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { theme } from "../theme/theme"; + +// Guard for #1898. +// +// `.storybook/vitest.setup.ts` used to call `setProjectAnnotations([...])` to +// hand the preview annotations to the Storybook vitest project. Since Storybook +// 10.3 `@storybook/addon-vitest` applies them automatically — and *skips* doing +// so when it finds such a setup file — so the file was removed and the +// `setupFiles` entry dropped from the `storybook` project in `vite.config.ts`. +// +// A green suite is not evidence that the automatic path works. Two things the +// setup file used to provision are silently losable: +// +// • `./preview`'s decorator, which wraps every story in `MantineProvider` +// with the project theme, and its `import "../src/App.css"`, which defines +// the `--inspector-*` tokens. Without them stories render unthemed and +// unstyled — and would very likely still pass their play functions. +// • `@storybook/addon-a11y/preview`, which runs axe after each play function. +// Without it the `a11y: { test: "error" }` parameter is inert and every +// story passes its accessibility check vacuously. +// +// The story below closes the first gap: it asserts the Mantine theme variables +// and the `App.css` tokens are actually present in the rendered document, so an +// unthemed render fails loudly rather than passing. It also asserts the preview +// `parameters` reached the story, which is the same channel the a11y parameter +// arrives on. The axe *runner* itself can't be introspected from a play +// function; it was verified out-of-band by temporarily giving a story a real +// violation and confirming the suite went red (see the PR for #1898). + +// The primary shade the theme pins for the light scheme — the value Mantine +// derives `--mantine-primary-color-filled` from. Read from the theme rather +// than hard-coded so a palette change can't silently invalidate the guard. +const LIGHT_PRIMARY_SHADE = 7; + +function expectedPrimaryColor(): string { + const palette = theme.colors?.[theme.primaryColor ?? ""]; + const color = palette?.[LIGHT_PRIMARY_SHADE]; + if (!color) throw new Error("theme is missing its primary color palette"); + return color; +} + +const meta: Meta<typeof Button> = { + title: "Meta/Preview Annotations", + component: Button, + args: { children: "Themed" }, +}; + +export default meta; +type Story = StoryObj<typeof Button>; + +export const ProjectAnnotationsApplied: Story = { + play: async ({ canvasElement, parameters }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Themed" })).toBeInTheDocument(); + + const root = getComputedStyle(document.documentElement); + + // `MantineProvider` (the `./preview` decorator) injects the theme's CSS + // variables onto `:root`. No provider, no variables. + expect(root.getPropertyValue("--mantine-primary-color-filled").trim()).toBe( + expectedPrimaryColor(), + ); + + // `--inspector-brand-primary` is defined in `App.css`, which only reaches + // the story through `./preview`'s stylesheet import — and it resolves + // *through* the Mantine variable above, so this covers both layers. + expect(root.getPropertyValue("--inspector-brand-primary").trim()).toBe( + expectedPrimaryColor(), + ); + + // The preview `parameters` merged into the story context — the same channel + // that carries `a11y: { test: "error" }` to the a11y addon. + expect(parameters.a11y).toMatchObject({ test: "error" }); + }, +}; diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index 3b99d672..a31c0169 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -379,7 +379,15 @@ export default defineConfig(({ command }) => { }, ], }, - setupFiles: [".storybook/vitest.setup.ts"], + // No `setupFiles`: since Storybook 10.3 `@storybook/addon-vitest` + // provisions the preview annotations (`.storybook/preview.tsx` plus + // `@storybook/addon-a11y/preview`) itself, and *skips* doing so when + // it finds a setup file calling `setProjectAnnotations` — so the old + // `.storybook/vitest.setup.ts` was both redundant and actively + // opting out of the automatic path (#1898). A green suite doesn't + // prove the automatic provisioning works (stories rendered without + // the Mantine decorator would very likely still pass), so + // `src/test/PreviewAnnotations.stories.tsx` asserts it directly. }, }, ], From d08a53027a3e6bdeea3d091af6a49ac2cdef5919 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:49:20 -0400 Subject: [PATCH 072/941] fix: dispatch the connect event after the listen stream is up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2: opening the stream after `dispatchTypedEvent("connect")` left a real gap. The managed list states start their initial `refresh()` from that event, so `tools/list` could go out ahead of `subscriptions/listen` — and a list the server changed in that window would notify nobody, leaving the UI stale with no way to notice. The handler registration, the initial logging level, and the stream open now all run before the `connect` dispatch, so no consumer can act on the connection until the notification channel is established (or its retry armed). Costs one listen round-trip on a modern connect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 24 +++++++++++++++++++ core/mcp/inspectorClient.ts | 11 +++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 91daa638..9c61266f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -373,6 +373,30 @@ describe("resource subscriptions era fork (#1630)", () => { ); }); + it("has the stream up before the connect event fires", async () => { + // The managed list states start their initial `refresh()` from the + // `connect` event, so the stream has to be established (or its retry + // armed) first: dispatching earlier would let `tools/list` go out ahead of + // `subscriptions/listen`, and a list the server changed in that window + // would notify nobody. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + let streamAtConnectEvent: boolean | undefined; + connected.addEventListener("connect", () => { + streamAtConnectEvent = internals(connected).modernSubscription !== null; + }); + await connected.connect(); + client = connected; + + expect(streamAtConnectEvent).toBe(true); + }); + it("connects anyway when the connect-time listen fails, and retries", async () => { // The handshake succeeded; every request-scoped feature works without the // stream, so the failure is handed to the reconnect machinery instead of diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index c9ec8207..4f9993a4 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,8 +1936,6 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - this.dispatchTypedEvent("connect"); - // Set initial logging level if configured and server supports it if (this.initialLoggingLevel && this.capabilities?.logging) { await this.client.setLoggingLevel( @@ -2067,6 +2065,15 @@ export class InspectorClient extends InspectorClientEventTarget { // server with no resources would have no way in, since a subscribe click // was the only thing that ever opened the stream (#1920). await this.openModernListenStreamOnConnect(); + + // Last, so the notification channel is established (or its retry armed) + // before any consumer acts on the connection. The managed list states + // start their initial `refresh()` from this event, so dispatching earlier + // would let `tools/list` go out ahead of `subscriptions/listen` — and a + // list the server changes in that window would notify nobody, leaving the + // UI stale with no way to notice (#1920). The cost is one listen + // round-trip added to connect on the modern era. + this.dispatchTypedEvent("connect"); } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; From d1135dfe7c425a021a7e7cdf6811c97157d5e801 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:54:44 -0400 Subject: [PATCH 073/941] chore(storybook): derive the primary shade from the theme in the guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard restated `primaryShade.light` as a literal 7, so re-pinning the theme's primary shade would have made it fail even with the preview annotations correctly applied — a guard that has to be edited alongside a legitimate theme change just trains people to edit it. Derive the shade from `theme.primaryShade`, handling both of Mantine's forms (a bare number and `{ light, dark }`) and falling back to Mantine's own default of 6 when a theme pins none. Verified by temporarily re-pinning the theme to `{ light: 8, dark: 8 }`: the guard still passes, where the hard-coded 7 would have failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../src/test/PreviewAnnotations.stories.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx index 50cafe5d..bca4fb48 100644 --- a/clients/web/src/test/PreviewAnnotations.stories.tsx +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -30,14 +30,29 @@ import { theme } from "../theme/theme"; // function; it was verified out-of-band by temporarily giving a story a real // violation and confirming the suite went red (see the PR for #1898). -// The primary shade the theme pins for the light scheme — the value Mantine -// derives `--mantine-primary-color-filled` from. Read from the theme rather -// than hard-coded so a palette change can't silently invalidate the guard. -const LIGHT_PRIMARY_SHADE = 7; +// Mantine's own default when a theme pins no `primaryShade` — the value it +// falls back to when deriving `--mantine-primary-color-filled`. +const MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE = 6; + +// The shade Mantine derives `--mantine-primary-color-filled` from. Everything +// here is read from the theme rather than restated, so re-pinning +// `primaryShade` or repainting the palette moves the expectation with it — a +// guard that had to be edited alongside a legitimate theme change would just +// train people to edit it, which is the opposite of what it's for. +// +// The light branch is the one that matters: `./preview` renders every story +// with `defaultColorScheme="light"` and an `initialGlobals.colorScheme` of +// `"light"`. `primaryShade` is `number | { light, dark }` in Mantine's types, +// so both forms are handled. +function lightPrimaryShade(): number { + const shade = theme.primaryShade; + if (typeof shade === "number") return shade; + return shade?.light ?? MANTINE_DEFAULT_LIGHT_PRIMARY_SHADE; +} function expectedPrimaryColor(): string { const palette = theme.colors?.[theme.primaryColor ?? ""]; - const color = palette?.[LIGHT_PRIMARY_SHADE]; + const color = palette?.[lightPrimaryShade()]; if (!color) throw new Error("theme is missing its primary color palette"); return color; } From b2dfd4099ab6a1d9615eb9298993b56194456540 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 10 Aug 2026 23:56:24 -0400 Subject: [PATCH 074/941] docs: correct three comments the connect-ordering change invalidated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3 (no new code findings, three stale-comment ones): - `openModernListenStreamOnConnect`'s generation guard was justified by a `connect` listener racing it — which the previous commit made impossible by dispatching `connect` after this call. The guard is still right, for reasons that survive the reorder: `statusChange` has already fired, and a concurrent `subscribeToResource` or `disconnect()` (whose `resetSubscriptionStream` bumps the generation) can supersede it. - `reconcileModernStreamStateAfterFailedRefresh`'s JSDoc still described its branches as "nothing subscribed" vs "URIs live"; they are the empty vs non-empty *filter* now, which differ exactly on the listChanged-only path. - The superseded-failure test carried the same stale `connect` rationale. Comments only; no behavior change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 14 ++++++---- core/mcp/inspectorClient.ts | 26 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 9c61266f..16289fc3 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -431,11 +431,15 @@ describe("resource subscriptions era fork (#1630)", () => { }); it("leaves a superseded connect-time failure to the refresh that owns the stream", async () => { - // `connect` is dispatched before the stream is opened, so a listener can - // start and acknowledge a newer refresh while this one is still awaiting - // its `listen()`. Reconciling anyway would arm a reconnect against a - // healthy stream and tear it down — the same ownership test the - // subscribe/unsubscribe paths make. + // Nothing about the ordering above makes this call the only refresh that + // can be in flight: `statusChange` has already fired, and a concurrent + // `subscribeToResource` — or a `disconnect()`, whose + // `resetSubscriptionStream` bumps the generation too — can supersede this + // one while its `listen()` is pending. Reconciling anyway would arm a + // reconnect against a stream that is healthy (or a session that is gone), + // so this makes the same ownership test the subscribe/unsubscribe paths + // make. Driven here by a stub that bumps twice, standing in for "someone + // else advanced it". const started = await startToolsOnlyServer({ tools: true }); const connected = new InspectorClient( { type: "streamable-http", url: started.url }, diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 4f9993a4..d36f7143 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4990,11 +4990,14 @@ export class InspectorClient extends InspectorClientEventTarget { * with backoff and settles on `"ended"` past the cap. * * Gated on the same generation test as `subscribeToResource` — see the long - * comment there. This call is *not* the only refresh that can be in flight: - * the `connect` event has already been dispatched, so a listener subscribing - * to a resource can start and acknowledge a newer refresh while this one is - * still awaiting its `listen()`. Reconciling unconditionally would then arm a - * reconnect that tears down a stream that is up and healthy. + * comment there. The `connect` event has deliberately *not* been dispatched + * yet (that is the point of running here), so a list-state consumer is not the + * risk; what is, is anything else that bumps the generation while this + * `listen()` is in flight. `statusChange` has already fired, and any + * concurrent call on this instance qualifies: a `subscribeToResource` from a + * caller restoring subscriptions, or a `disconnect()` — whose + * `resetSubscriptionStream` bumps the generation too, making a reconcile here + * arm a reconnect for a session that is already gone. */ private async openModernListenStreamOnConnect(): Promise<void> { if (!this.isModernEra() || !this.wantsModernStream()) return; @@ -5148,10 +5151,11 @@ export class InspectorClient extends InspectorClientEventTarget { * refresh owns the state as well as the filter — so both call sites gate on * the generation first. * - * The empty case is the ordinary one: nothing subscribed, no stream, inactive. - * The non-empty one exists because a failed re-listen leaves - * `modernSubscription` null with URIs still subscribed, and nothing else will - * notice: the reconnect machinery is reachable only from a stream that closed + * The two branches are the empty and non-empty *filter* (#1920) — which is + * "nothing subscribed" only when no list-change opt-in is live. The empty case + * is the ordinary one: nothing to listen for, no stream, inactive. The + * non-empty one exists because a failed re-listen leaves `modernSubscription` + * null with the filter still wanting a stream, and nothing else will notice: the reconnect machinery is reachable only from a stream that closed * or a reconnect that failed, and neither happened here. Left alone, the state * keeps whatever the last success (or the optimistic `"connecting"`) wrote — a * badge that will never change over subscriptions the server may never have @@ -5159,11 +5163,11 @@ export class InspectorClient extends InspectorClientEventTarget { * Subscribe early-returns on the URI already being in the set). * * So it reconnects rather than settling for an honest-but-dead `"ended"`: - * every other route to "stream gone, URIs live" either expects the close or + * every other route to "stream gone, filter live" either expects the close or * has exhausted the retry cap, and this is the one that has made no attempt * at all. `scheduleModernReconnect` fits as-is — a user-initiated refresh * already reset `modernReconnectAttempts`, so it starts at the base delay; the - * timer bails on a terminal status or an emptied set; and past the cap + * timer bails on a terminal status or an emptied filter; and past the cap * `onModernReconnectFailed` lands on the same `"ended"` badge. The state * therefore becomes true or ends after a real attempt. The caller still sees * its error either way — the retry is about the subscriptions, not the call. From 2a4e93c621ac58928a85811acff07acfe68f1f40 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 00:05:56 -0400 Subject: [PATCH 075/941] fix: only announce the connection if it is still live after the stream open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 4: every await before the `connect` dispatch is a window for a `disconnect()` or a transport close to overtake the connect, and the listen round-trip widened it. Announcing anyway restarts every managed list refresh against a session being torn down or already dead. The dispatch is now gated on `status === "connected" && !disconnecting` — the second because an explicit teardown claims ownership before it awaits `client.close()`, leaving the status untouched until that block finishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .../inspectorClient-subscriptions-era.test.ts | 46 +++++++++++++++++++ core/mcp/inspectorClient.ts | 12 ++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts index 16289fc3..8c2a005a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-subscriptions-era.test.ts @@ -397,6 +397,52 @@ describe("resource subscriptions era fork (#1630)", () => { expect(streamAtConnectEvent).toBe(true); }); + it("does not announce the connection when a disconnect overtakes the stream open", async () => { + // The listen round-trip widened the window between the handshake and the + // `connect` announcement, so a `disconnect()` can land inside it. + // Announcing anyway would restart every managed list refresh against a + // session being torn down. + const started = await startToolsOnlyServer({ tools: true }); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + client = connected; + let connectAnnounced = false; + connected.addEventListener("connect", () => { + connectAnnounced = true; + }); + + // Hold the stream open so the disconnect lands while it is in flight. + // `openStarted` is what makes the race deterministic: the stub is only + // reached after the handshake, so disconnecting before it runs would leave + // `releaseOpen` unset and hang the connect. + const int = internals(connected); + let releaseOpen: () => void = () => {}; + let markOpenStarted: () => void = () => {}; + const openStarted = new Promise<void>((resolve) => { + markOpenStarted = resolve; + }); + int.refreshModernSubscription = () => { + markOpenStarted(); + return new Promise<void>((resolve) => { + releaseOpen = resolve; + }); + }; + + const connecting = connected.connect(); + await openStarted; + const disconnecting = connected.disconnect(); + releaseOpen(); + await connecting; + await disconnecting; + + expect(connectAnnounced).toBe(false); + }); + it("connects anyway when the connect-time listen fails, and retries", async () => { // The handshake succeeded; every request-scoped feature works without the // stream, so the failure is handed to the reconnect machinery instead of diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d36f7143..b0b809f3 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -2073,7 +2073,17 @@ export class InspectorClient extends InspectorClientEventTarget { // list the server changes in that window would notify nobody, leaving the // UI stale with no way to notice (#1920). The cost is one listen // round-trip added to connect on the modern era. - this.dispatchTypedEvent("connect"); + // + // …which is also why the announcement is conditional. Every await above + // is a window for a `disconnect()` or a transport `onclose`/`onerror` to + // overtake this connect, and the listen round-trip widened it. Announcing + // then would restart every managed list refresh against a session being + // torn down or already dead. `disconnecting` covers the teardown that has + // claimed ownership but is still awaiting `client.close()` — the status is + // whatever it was until that block finishes. + if (this.status === "connected" && !this.disconnecting) { + this.dispatchTypedEvent("connect"); + } } catch (error) { if (!isConnectAuthRecoveryError(error)) { this.status = "error"; From 31c41d3ad61f1a8c0ff9eade0177e93fe8c3f018 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:07:04 -0400 Subject: [PATCH 076/941] chore: declare the MCP SDK packages only at the repo root (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four v2 SDK packages (`client`, `core`, `server`, `server-legacy`) plus `ext-apps` were declared in the root manifest *and* again in clients/web, cli, and tui, so each client installed its own copy. Node resolution walks up and the root install is on every client's chain — and the root manifest is already the documented source of truth for what ships — so the per-client entries were duplicates of a decision made at the root, and they had already drifted: before this change the tree carried ext-apps 1.7.4 at the root and 1.7.5 under clients/web, dragging in two copies of the v1 `@modelcontextprotocol/sdk` (1.29.0 and 1.30.0) through ext-apps' peer dependency. Nothing imports the v1 SDK; it is not in any manifest of ours, only a peer of ext-apps, so consolidating ext-apps is the only lever over it. After a clean install there is now exactly one copy of each, at the root. `npm update` then moves ext-apps to 1.7.5 and its SDK peer to 1.30.0, whose widened `@hono/node-server` range (`^1.19.9 || ^2.0.5`) also drops a nested duplicate of that package. `express` needed a real home to make this work. `test-servers/src` imports it, but no manifest declared it: it was reaching `clients/cli/node_modules` only as a peer of `express-rate-limit` under `@modelcontextprotocol/server-legacy`, so removing that entry took express with it and every cli test that spawns a test server failed to resolve it. It is now a root devDependency — test-servers is root-owned code with no manifest of its own — and `vitest.shared.mts` resolves it from the root, joining `yaml`, which was already the precedent for exactly this case. The clients/web duplicate is dropped; its only reference is a type-only import covered by `@types/express`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- clients/cli/package-lock.json | 1022 +------------------------------ clients/cli/package.json | 4 - clients/tui/package-lock.json | 77 +-- clients/tui/package.json | 2 - clients/web/package-lock.json | 1069 +-------------------------------- clients/web/package.json | 6 - package-lock.json | 86 +-- package.json | 1 + vitest.shared.mts | 13 +- 9 files changed, 47 insertions(+), 2233 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index ee8ea1e4..5db50d28 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -8,10 +8,6 @@ "name": "@modelcontextprotocol/inspector-cli", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -807,76 +803,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -2292,20 +2218,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2403,31 +2315,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -2472,15 +2359,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2491,37 +2369,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -2574,29 +2421,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2604,47 +2428,11 @@ "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2659,6 +2447,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2719,15 +2508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2738,58 +2518,6 @@ "node": ">=8" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", @@ -2797,19 +2525,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2852,13 +2567,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3061,37 +2769,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3102,68 +2779,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3231,28 +2846,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3303,26 +2896,6 @@ "dev": true, "license": "ISC" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3338,55 +2911,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3413,19 +2937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3436,32 +2947,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3469,42 +2954,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3525,31 +2974,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "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==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3606,13 +3030,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3632,6 +3049,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3673,15 +3091,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4100,66 +3509,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -4193,6 +3542,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -4233,38 +3583,16 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4288,29 +3616,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -4379,16 +3684,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -4403,22 +3698,12 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4493,15 +3778,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -4628,20 +3904,6 @@ ], "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4652,53 +3914,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4820,23 +4041,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -4858,12 +4062,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4877,63 +4075,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4946,87 +4092,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5079,15 +4150,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -5229,15 +4291,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5342,39 +4395,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5436,15 +4456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5455,15 +4466,6 @@ "punycode": "^2.1.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -5652,6 +4654,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5690,13 +4693,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", diff --git a/clients/cli/package.json b/clients/cli/package.json index 598fc7f3..fedd355c 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -32,10 +32,6 @@ "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0b3dbd3e..9b7de4d5 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -8,8 +8,6 @@ "name": "@modelcontextprotocol/inspector-tui", "license": "MIT", "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -1054,36 +1052,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -2830,6 +2798,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3247,27 +3216,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3793,6 +3741,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3834,15 +3783,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4529,6 +4469,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4608,15 +4549,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -4964,6 +4896,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4976,6 +4909,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6211,6 +6145,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/clients/tui/package.json b/clients/tui/package.json index 9a3278bc..18f71e8a 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -27,8 +27,6 @@ "format:check": "prettier --check src __tests__ \"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}\"" }, "dependencies": { - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 22c54dd7..ace9657b 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -15,11 +15,6 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -64,7 +59,6 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", @@ -1440,146 +1434,6 @@ "react": ">=16" } }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", - "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "express-rate-limit": "^8.2.1", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "express": "^4.18.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "express": { - "optional": true - } - } - }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -3310,6 +3164,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/addon-a11y": { @@ -4528,19 +4383,6 @@ "dev": true, "license": "MIT" }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -4580,24 +4422,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4754,43 +4578,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -4882,15 +4669,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4901,35 +4679,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5128,69 +4877,12 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "license": "MIT" }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -5220,6 +4912,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5330,15 +5023,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -5407,26 +5091,6 @@ "csstype": "^3.0.2" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { "version": "1.5.399", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", @@ -5444,15 +5108,6 @@ "node": ">=14" } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -5475,15 +5130,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -5500,18 +5146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -5564,12 +5198,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5837,36 +5465,6 @@ "node": ">=0.10.0" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -5877,68 +5475,6 @@ "node": ">=12.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -6025,27 +5561,6 @@ "node": ">=16.0.0" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", @@ -6110,24 +5625,6 @@ "node": ">=0.4.x" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6162,30 +5659,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -6195,19 +5668,6 @@ "node": ">=6" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6252,18 +5712,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -6301,18 +5749,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6462,42 +5898,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6544,36 +5944,12 @@ "node": ">=8" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -6707,12 +6083,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6732,6 +6102,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -6773,15 +6144,6 @@ "node": ">=8" } }, - "node_modules/jose": { - "version": "6.2.7", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", - "integrity": "sha512-hq1OB1bALKfydZNoViyg6hPVGV4i93ny9Op+n4zP5RSf7SCZEXa/TsG2O3IEr7+WlHRTPnpqDmHfMH6qXAD60w==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -6829,13 +6191,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -7338,15 +6693,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7629,31 +6975,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8217,31 +7538,6 @@ ], "license": "MIT" }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -8355,15 +7651,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -8383,18 +7670,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -8418,27 +7693,6 @@ "node": ">=14.0.0" } }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -8637,15 +7891,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8660,6 +7905,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8698,16 +7944,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -8800,15 +8036,6 @@ "node": ">= 6" } }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -9042,19 +8269,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9065,56 +8279,12 @@ "node": ">=6" } }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -9629,22 +8799,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -9666,12 +8820,6 @@ "node": ">=10" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9691,61 +8839,11 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -9758,83 +8856,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9911,15 +8938,6 @@ "dev": true, "license": "MIT" }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -10248,15 +9266,6 @@ "node": ">=14.0.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -10930,37 +9939,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11110,15 +10088,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unplugin": { "version": "2.3.11", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", @@ -11274,15 +10243,6 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -11559,6 +10519,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11597,12 +10558,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", @@ -11685,16 +10640,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index a347376e..66ad0d4c 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -41,11 +41,6 @@ "@mantine/form": "^8.3.17", "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "atomically": "^2.1.1", @@ -87,7 +82,6 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-storybook": "^10.5.5", - "express": "^5.2.1", "globals": "^17.4.0", "happy-dom": "^20.9.0", "playwright": "^1.58.2", diff --git a/package-lock.json b/package-lock.json index 58f21e7a..81d9bd03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", @@ -330,9 +331,9 @@ } }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", - "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", "license": "MIT", "workspaces": [ "examples/*" @@ -359,13 +360,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "license": "MIT", "peer": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -399,19 +400,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { - "version": "1.19.17", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", - "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@modelcontextprotocol/server": { "version": "2.0.0-beta.5", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", @@ -1267,7 +1255,6 @@ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", - "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1418,7 +1405,6 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "license": "MIT", - "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", @@ -1480,7 +1466,6 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -1494,7 +1479,6 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -1602,7 +1586,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1634,7 +1617,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -1644,7 +1626,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.6.0" } @@ -1767,7 +1748,6 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -1781,8 +1761,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/emoji-regex": { "version": "10.6.0", @@ -1795,7 +1774,6 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -1817,7 +1795,6 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1827,7 +1804,6 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -1837,7 +1813,6 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -1859,8 +1834,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "2.0.0", @@ -2068,7 +2042,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2099,7 +2072,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2242,7 +2214,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2302,7 +2273,6 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -2312,7 +2282,6 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -2336,7 +2305,6 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2358,7 +2326,6 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", - "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -2383,7 +2350,6 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", - "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -2423,7 +2389,6 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2436,7 +2401,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -2449,7 +2413,6 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "license": "MIT", - "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2673,7 +2636,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.10" } @@ -2768,8 +2730,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "2.1.0", @@ -3146,7 +3107,6 @@ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" } @@ -3156,7 +3116,6 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3166,7 +3125,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3179,7 +3137,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3189,7 +3146,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -3262,7 +3218,6 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3281,7 +3236,6 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4" }, @@ -3303,7 +3257,6 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", - "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -3316,7 +3269,6 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", - "peer": true, "dependencies": { "wrappy": "1" } @@ -3409,7 +3361,6 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -3447,7 +3398,6 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -3592,7 +3542,6 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", - "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -3616,7 +3565,6 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -3638,7 +3586,6 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -3767,7 +3714,6 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -3830,7 +3776,6 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -3857,7 +3802,6 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", - "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -3904,7 +3848,6 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -3924,7 +3867,6 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", - "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -3941,7 +3883,6 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -3960,7 +3901,6 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", - "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -4214,7 +4154,6 @@ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "license": "MIT", - "peer": true, "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", @@ -4460,8 +4399,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ws": { "version": "8.21.0", diff --git a/package.json b/package.json index d3467cf6..f165824a 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "eslint": "^10.8.0", + "express": "^5.2.1", "globals": "^17.7.0", "prettier": "3.8.4", "typescript": "~5.9.3", diff --git a/vitest.shared.mts b/vitest.shared.mts index 060e099f..f52919cc 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -82,9 +82,20 @@ export function vitestSharedPaths(clientDir: string) { find: /^@napi-rs\/keyring$/, replacement: path.resolve(dirname, "node_modules/@napi-rs/keyring"), }, + // `express` and `yaml` resolve from the **repo root**, unlike every pin + // above. Both are reached only through root-owned code that has no manifest + // of its own — `test-servers/src` imports express, and `core`/the root + // tooling uses yaml — so the root is where they are declared and the only + // place a client's resolution chain is guaranteed to find them. Pointing + // these at `<client>/node_modules` is what broke when the MCP packages moved + // to the root (#1970): express was never declared by a client at all, it + // arrived in `clients/cli` as a peer of `express-rate-limit` under + // `@modelcontextprotocol/server-legacy`, so removing that manifest entry + // took express with it and every cli test that spawns a test server failed + // to resolve it. { find: /^express$/, - replacement: path.resolve(dirname, "node_modules/express"), + replacement: path.resolve(repoRoot, "node_modules/express"), }, { find: /^yaml$/, From 51bbe9996b8b051812c888ebcda33ba688366439 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:15:08 -0400 Subject: [PATCH 077/941] fix(web): keep the text being typed in a schema number field (#1888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SchemaForm` drove Mantine's `NumberInput` directly off the parent's numeric value. `NumberInput` reports the raw string while the text does not parse, so pressing `.` after `1` produced `"1."`, which the handler collapsed to `undefined` — and because the control is fully controlled, the `value` prop immediately rewrote the box back to `1`. The decimal point was discarded on every keystroke, making `1.5` unreachable. A trailing zero, a lone leading `-`, and an exponent failed the same way. Introduce `SchemaNumberInput`, which holds the in-progress text as the source of truth for what is displayed while the parent still only ever sees a `number | undefined`. Draft and value re-sync only when they genuinely diverge, via `useValueChange` (adjust-state-during-render) rather than an effect, so an external reset still rewrites the box but an in-progress `"1."` is left alone. Also pass `allowDecimal` from the schema, so an `integer` field rejects the decimal point instead of accepting a value its schema forbids — matching what the TUI already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../groups/SchemaForm/SchemaForm.test.tsx | 135 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 87 ++++++++++- 2 files changed, 216 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c534fb29..a381add1 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; @@ -228,6 +229,140 @@ describe("SchemaForm", () => { expect(lastCall.count).toBeUndefined(); }); + describe("number fields (#1888)", () => { + // The real callers keep the form values in state and feed them back in, so + // the bug only reproduces against a genuinely controlled SchemaForm: an + // uncontrolled render never rewrites the box and would pass either way. + function ControlledSchemaForm({ + schema, + initialValues = {}, + onChange, + }: { + schema: InspectorFormSchema; + initialValues?: Record<string, unknown>; + onChange: (values: Record<string, unknown>) => void; + }) { + const [values, setValues] = + useState<Record<string, unknown>>(initialValues); + return ( + <SchemaForm + schema={schema} + values={values} + onChange={(next) => { + setValues(next); + onChange(next); + }} + /> + ); + } + + const numberSchema: InspectorFormSchema = { + type: "object", + properties: { + divisor: { type: "number", title: "Divisor" }, + }, + }; + + it("lets a decimal be typed all the way through", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("1.5"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1.5 }); + }); + + it("keeps the trailing decimal point visible mid-entry", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1."); + // The point survives on screen even though "1." parses to plain 1. + expect(input.value).toBe("1."); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1 }); + }); + + it("keeps a trailing zero after the decimal point", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.50"); + expect(input.value).toBe("1.50"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 1.5 }); + }); + + it("reports a lone minus sign as no value while leaving it typed", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "-"); + expect(input.value).toBe("-"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: undefined }); + await user.type(input, "2.5"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: -2.5 }); + }); + + it("rejects a decimal point in an integer field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + count: { type: "integer", title: "Count" }, + }, + }; + renderWithMantine( + <ControlledSchemaForm schema={schema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Count/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("15"); + expect(onChange).toHaveBeenLastCalledWith({ count: 15 }); + }); + + it("re-syncs the displayed text when the value changes externally", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({ + divisor: 1.5, + }); + return ( + <> + <button type="button" onClick={() => setValues({ divisor: 42 })}> + Load example + </button> + <SchemaForm + schema={numberSchema} + values={values} + onChange={(next) => { + setValues(next); + onChange(next); + }} + /> + </> + ); + } + renderWithMantine(<Harness />); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + expect(input.value).toBe("1.5"); + await user.click(screen.getByRole("button", { name: "Load example" })); + expect(input.value).toBe("42"); + }); + }); + it("falls back to empty/const labels for oneOf items missing const and title", () => { const onChange = vi.fn(); const schema: InspectorFormSchema = { diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 30e39795..0e67d854 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,7 +8,9 @@ import { Text, TextInput, } from "@mantine/core"; +import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ @@ -49,6 +51,79 @@ function toEnumData( return values; } +/** + * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the + * field. It emits a `number` once the text parses cleanly, and the **raw string** + * while it does not — `""` when cleared, but also the in-progress `"1."`, `"-"`, + * and `"1e"`. Anything that is not a finite number becomes `undefined`, which is + * how an absent optional argument is represented everywhere else in this form. + */ +function toNumericValue(raw: string | number): number | undefined { + if (typeof raw === "number") { + return Number.isFinite(raw) ? raw : undefined; + } + if (raw.trim() === "") { + return undefined; + } + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : undefined; +} + +interface SchemaNumberInputProps { + label: string; + description?: string; + withAsterisk: boolean; + disabled: boolean; + value: number | undefined; + min?: number; + max?: number; + allowDecimal: boolean; + onChange: (value: number | undefined) => void; +} + +/** + * A `NumberInput` that keeps the text the user is typing, not just the number it + * currently parses to. + * + * Driving `NumberInput` directly off the parent's numeric value makes a decimal + * impossible to enter (#1888): typing `.` after `1` produces the unparseable + * string `"1."`, the numeric value stays `1`, and the controlled `value` prop + * immediately rewrites the box back to `"1"` — so the `.` vanishes and `1.5` can + * never be reached. Trailing zeros (`"1.50"`) and a lone leading `"-"` fail the + * same way. + * + * So the raw text is held here as the source of truth for what is *displayed*, + * while the parent still only ever sees a `number | undefined`. The two are + * re-synced only when the parent's value genuinely diverges from what the draft + * parses to, which leaves an external reset (a cleared form, a loaded example) + * working while an in-progress `"1."` — whose parse is `1`, matching the value we + * just emitted — is left alone. + */ +function SchemaNumberInput({ + value, + onChange, + ...inputProps +}: SchemaNumberInputProps) { + const [draft, setDraft] = useState<string | number>(value ?? ""); + + useValueChange(value, (next) => { + if (!Object.is(toNumericValue(draft), next)) { + setDraft(next ?? ""); + } + }); + + return ( + <NumberInput + {...inputProps} + value={draft} + onChange={(next) => { + setDraft(next); + onChange(toNumericValue(next)); + }} + /> + ); +} + export interface SchemaFormProps { schema: InspectorFormSchema; values: Record<string, unknown>; @@ -156,19 +231,19 @@ export function SchemaForm({ // number or integer if (fieldSchema.type === "number" || fieldSchema.type === "integer") { return ( - <NumberInput + <SchemaNumberInput key={fieldName} label={label} description={description} withAsterisk={isRequired} disabled={disabled} - value={(rawValue as number) ?? ""} + value={rawValue as number | undefined} min={fieldSchema.minimum} max={fieldSchema.maximum} - onChange={(val) => { - const numericValue = typeof val === "string" ? undefined : val; - handleFieldChange(fieldName, numericValue); - }} + // An `integer` field rejects the decimal point outright rather than + // accepting a value the schema forbids. + allowDecimal={fieldSchema.type === "number"} + onChange={(val) => handleFieldChange(fieldName, val)} /> ); } From 6f47eaa1438fe6907ba19a1737e0bea3bf99568b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:23:43 -0400 Subject: [PATCH 078/941] fix: render a tool result's structuredContent in the Tools screen (#1908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool declaring an `outputSchema` returns its real payload in `structuredContent`; the `content[]` blocks usually only summarize it. v1 rendered that payload as its own inspectable JSON section — v2's ToolResultPanel only ever walked `result.content`, so the payload was dropped with no label, section, or hint that it existed. Adds a `StructuredOutputPanel` group: a collapsible "Structured Output" box (bordered like the existing "Resource Links" box) rendering the payload as pretty-printed, syntax-highlighted, copyable JSON, bounded so a large payload scrolls within itself. ToolResultPanel renders it below the content blocks — and also when `content` is empty (where "No results yet" was simply wrong) and below the alert on an error result, so it is never silently dropped. Adds a `list_items` test-server preset (nested payload, the issue's repro shape) and a `structured-output-http.json` showcase config, documented in the README. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- README.md | 7 ++ .../StructuredOutputPanel.stories.tsx | 90 ++++++++++++++++ .../StructuredOutputPanel.test.tsx | 87 +++++++++++++++ .../StructuredOutputPanel.tsx | 102 ++++++++++++++++++ .../ToolResultPanel.stories.tsx | 18 ++++ .../ToolResultPanel/ToolResultPanel.test.tsx | 72 +++++++++++++ .../ToolResultPanel/ToolResultPanel.tsx | 38 +++++-- .../configs/structured-output-http.json | 15 +++ test-servers/src/preset-registry.ts | 3 + test-servers/src/test-server-fixtures.ts | 42 ++++++++ 10 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx create mode 100644 clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx create mode 100644 test-servers/configs/structured-output-http.json diff --git a/README.md b/README.md index 13792764..c691e8e9 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | | `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | | `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | | `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | @@ -206,6 +207,12 @@ Under SDK v2 a `tools/call` rejecting with `-32602` renders as a distinct error Turn on **"Fetch Lists One Page at a Time"** (Server Settings — the `paginatedLists` setting, or the **Paginated** switch in a list sidebar) and the lists load page 1 only (4 items) with a **Load next page** control and an _N pages loaded_ status. Each click fetches the next 4 and appends them; Refresh resets to page 1. With the switch off (the default), the same lists auto-aggregate all three pages on connect. +#### Structured output + +`structured-output-http.json` serves `list_items` (nested `structuredContent` — objects inside arrays inside an object, the shape from [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908)), `get_temp` (a flat three-key payload), and `echo` (no `outputSchema` at all). It is a plain streamable-HTTP server — connect with the **default (legacy)** protocol era. + +Run `list_items` from the Tools tab: the result panel shows the `content[]` text summary ("Found 2 items.") **and** a collapsible **Structured Output** section rendering the schema-validated payload as pretty-printed, copyable JSON. That section is what v2 was dropping — a tool declaring an `outputSchema` returns its real data there, and the text block usually only summarizes it. Run `echo` to confirm the section is absent when a result carries no `structuredContent`. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx new file mode 100644 index 00000000..443c78ab --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; +import { StructuredOutputPanel } from "./StructuredOutputPanel"; + +const nested = { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, +}; + +const flat = { + temperature: 25, + unit: "C", + city: "San Francisco", +}; + +// Long enough that the payload exceeds the section's max height and scrolls +// within it rather than pushing the rest of the result panel down. +const large = { + rows: Array.from({ length: 60 }, (_, index) => ({ + id: index + 1, + label: `Row ${index + 1}`, + value: index * 7, + })), + total: 60, +}; + +const meta: Meta<typeof StructuredOutputPanel> = { + title: "Groups/StructuredOutputPanel", + component: StructuredOutputPanel, +}; + +export default meta; +type Story = StoryObj<typeof StructuredOutputPanel>; + +export const Nested: Story = { + args: { + structuredContent: nested, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + // The section starts expanded, so the payload is visible without a click. + // Asserted on the container's text: once the Prism grammar loads, the JSON + // is split across per-token elements, so no single node holds the value. + await waitFor(() => + expect(canvasElement.textContent).toContain('"Item A"'), + ); + }, +}; + +export const Flat: Story = { + args: { + structuredContent: flat, + }, +}; + +export const Large: Story = { + args: { + structuredContent: large, + }, +}; + +// Collapsing hides the payload; expanding brings it back. +export const Collapsed: Story = { + args: { + structuredContent: nested, + defaultExpanded: false, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const toggle = canvas.getByRole("button", { + name: "Expand structured output", + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + await waitFor(() => + expect( + canvas.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"), + ); + await waitFor(() => + expect(canvasElement.textContent).toContain('"Item A"'), + ); + }, +}; diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx new file mode 100644 index 00000000..abdaf91e --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { StructuredOutputPanel } from "./StructuredOutputPanel"; + +// The real CodeHighlight dynamic-imports the Prism runtime and each grammar +// (its own behavior is covered in CodeHighlight.test.tsx). Stub it so these +// tests assert on the JSON handed to it, synchronously. +vi.mock("../../elements/CodeHighlight/CodeHighlight", () => ({ + CodeHighlight: ({ language, code }: { language: string; code: string }) => ( + <pre data-testid="code-highlight" data-language={language}> + {code} + </pre> + ), +})); + +const structuredContent = { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, +}; + +describe("StructuredOutputPanel", () => { + it("renders a labeled section with the payload as pretty-printed JSON", () => { + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + const code = screen.getByTestId("code-highlight"); + expect(code).toHaveAttribute("data-language", "json"); + expect(code).toHaveTextContent(/"total": 2/); + // Nested values are inspectable field by field, not summarized away. + expect(code).toHaveTextContent(/"name": "Item A"/); + expect(code).toHaveTextContent(/"tags"/); + }); + + it("starts expanded by default", () => { + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + expect( + screen.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("starts collapsed when defaultExpanded is false", () => { + renderWithMantine( + <StructuredOutputPanel + structuredContent={structuredContent} + defaultExpanded={false} + />, + ); + expect( + screen.getByRole("button", { name: "Expand structured output" }), + ).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles between expanded and collapsed", async () => { + const user = userEvent.setup(); + renderWithMantine( + <StructuredOutputPanel structuredContent={structuredContent} />, + ); + await user.click( + screen.getByRole("button", { name: "Collapse structured output" }), + ); + const toggle = screen.getByRole("button", { + name: "Expand structured output", + }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await user.click(toggle); + expect( + screen.getByRole("button", { name: "Collapse structured output" }), + ).toHaveAttribute("aria-expanded", "true"); + }); + + it("renders an empty structured payload rather than nothing", () => { + renderWithMantine(<StructuredOutputPanel structuredContent={{}} />); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("code-highlight")).toHaveTextContent("{}"); + }); +}); diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx new file mode 100644 index 00000000..0f088e97 --- /dev/null +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { + Collapse, + Group, + Paper, + ScrollArea, + Stack, + Title, +} from "@mantine/core"; +import type { CallToolResult } from "@modelcontextprotocol/client"; +import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; +import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; + +export interface StructuredOutputPanelProps { + /** The result's `structuredContent` — the tool's schema-validated payload. */ + structuredContent: NonNullable<CallToolResult["structuredContent"]>; + /** Whether the section starts expanded. Defaults to `true`. */ + defaultExpanded?: boolean; +} + +// Bordered box matching the "Resource Links" group in the result panel, so the +// two supplementary sections of a tool result read as siblings. +const StructuredBox = Paper.withProps({ + withBorder: true, + radius: "md", + p: "md", + variant: "panel", +}); + +const StructuredInner = Stack.withProps({ + gap: "sm", +}); + +const HeaderRow = Group.withProps({ + justify: "space-between", + wrap: "nowrap", +}); + +// h4 (size h5) for the same reason as the "Resource Links" heading: the panel's +// "Results" title is h3, so a sub-box heading is h4 and the heading order never +// skips a level (axe `heading-order`). +const StructuredHeader = Title.withProps({ + order: 4, + size: "h5", +}); + +// Caps the payload so a large structured result scrolls within the box instead +// of pushing the content blocks out of view. `Autosize` sizes to the content up +// to `mah`, so a small object still takes only what it needs. +const StructuredScroll = ScrollArea.Autosize.withProps({ + mah: 400, + type: "auto", + scrollbars: "y", + offsetScrollbars: true, +}); + +/** + * Collapsible "Structured Output" section for a tool result's + * `structuredContent` (#1908). A tool declaring an `outputSchema` returns its + * real payload here — the `content[]` blocks usually only summarize it — so v1 + * rendered it as its own inspectable JSON section. Without this, the payload is + * dropped from the Tools screen entirely, with no hint it was ever returned. + * + * The JSON is pretty-printed and syntax-highlighted through {@link ContentViewer} + * (an `application/json` text block), so it is copyable and scannable field by + * field. + */ +export function StructuredOutputPanel({ + structuredContent, + defaultExpanded = true, +}: StructuredOutputPanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( + <StructuredBox> + <StructuredInner> + <HeaderRow> + <StructuredHeader>Structured Output</StructuredHeader> + <ExpandToggle + expanded={expanded} + onToggle={() => setExpanded((value) => !value)} + ariaLabel={`${expanded ? "Collapse" : "Expand"} structured output`} + /> + </HeaderRow> + {/* Content stays mounted across a collapse (Mantine `Collapse`), so the + highlighted JSON isn't re-rendered from scratch on every toggle. */} + <Collapse in={expanded}> + <StructuredScroll> + <ContentViewer + block={{ + type: "text", + text: JSON.stringify(structuredContent, null, 2), + }} + mimeType="application/json" + copyable + /> + </StructuredScroll> + </Collapse> + </StructuredInner> + </StructuredBox> + ); +} diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx index 1134f657..683bb423 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.stories.tsx @@ -173,6 +173,24 @@ export const ResourceLinksWithLongText: Story = { decorators: fillHeightDecorators, }; +// A tool declaring an `outputSchema` returns its real payload in +// `structuredContent`, which the text block only summarizes (#1908). It gets +// its own collapsible "Structured Output" section below the content blocks. +export const StructuredOutput: Story = { + args: { + result: { + content: [{ type: "text", text: "Found 2 items." }], + structuredContent: { + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, + }, + }, + }, +}; + export const ErrorResult: Story = { args: { result: { diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx index 1ebaea7b..1be9f2c3 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx @@ -109,6 +109,78 @@ describe("ToolResultPanel", () => { expect(screen.getByText("divider")).toBeInTheDocument(); }); + describe("structuredContent (#1908)", () => { + const structured = { items: [{ id: 1, name: "Item A" }], total: 1 }; + + it("renders a Structured Output section alongside the content blocks", () => { + renderWithMantine( + <ToolResultPanel + result={{ ...okResult, structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.getByText("ok")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("omits the section when the result has no structuredContent", () => { + renderWithMantine( + <ToolResultPanel result={okResult} onClear={() => {}} />, + ); + expect( + screen.queryByRole("heading", { name: "Structured Output" }), + ).not.toBeInTheDocument(); + }); + + it("renders the section instead of the empty state when content is empty", () => { + renderWithMantine( + <ToolResultPanel + result={{ content: [], structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.queryByText("No results yet")).not.toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("renders the section below the alert on an error result", () => { + renderWithMantine( + <ToolResultPanel + result={{ ...errorResult, structuredContent: structured }} + onClear={() => {}} + />, + ); + expect(screen.getByText("Tool Error")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + + it("renders the section alongside a Resource Links box", () => { + renderWithMantine( + <ToolResultPanel + result={{ + content: [ + { type: "resource_link", uri: "demo://r/1", name: "One" }, + ], + structuredContent: structured, + }} + onClear={() => {}} + />, + ); + expect( + screen.getByRole("heading", { name: "Resource Links" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Structured Output" }), + ).toBeInTheDocument(); + }); + }); + it("invokes onClear when the close button is clicked", async () => { const user = userEvent.setup(); const onClear = vi.fn(); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 08db06a1..33ca7761 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -14,6 +14,7 @@ import type { } from "@modelcontextprotocol/client"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { ResourceLink } from "../ResourceLink/ResourceLink"; +import { StructuredOutputPanel } from "../StructuredOutputPanel/StructuredOutputPanel"; import { resultHasResourceLinks } from "./toolResultUtils"; export interface ToolResultPanelProps { @@ -217,6 +218,16 @@ export function ToolResultPanel({ // available height (and scrolls inside). Plain text/image results keep the // scroll-within-card body so a short result doesn't reserve empty height. const hasLinks = resultHasResourceLinks(result); + // A tool with an `outputSchema` returns its real payload in + // `structuredContent`, which the `content[]` blocks typically only summarize + // (#1908). Render it as its own section — including alongside an error or an + // empty `content` array, so it is never silently dropped. + const structuredNode = result.structuredContent ? ( + <StructuredOutputPanel + key="structured-output" + structuredContent={result.structuredContent} + /> + ) : null; const segmentNodes = segments.map((segment) => { if (segment.kind === "links") { @@ -254,22 +265,31 @@ export function ToolResultPanel({ </HeaderRow> {result.isError ? ( <ResultScroll> - <ErrorAlert> - {result.content - .filter((b) => b.type === "text") - .map((b) => b.text) - .join("\n")} - </ErrorAlert> + <ResultStack> + <ErrorAlert> + {result.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n")} + </ErrorAlert> + {structuredNode} + </ResultStack> </ResultScroll> - ) : result.content.length === 0 ? ( + ) : result.content.length === 0 && !structuredNode ? ( <ResultScroll> <Text c="dimmed">No results yet</Text> </ResultScroll> ) : hasLinks ? ( - <FillStack>{segmentNodes}</FillStack> + <FillStack> + {segmentNodes} + {structuredNode} + </FillStack> ) : ( <ResultScroll> - <ResultStack>{segmentNodes}</ResultStack> + <ResultStack> + {segmentNodes} + {structuredNode} + </ResultStack> </ResultScroll> )} </PanelStack> diff --git a/test-servers/configs/structured-output-http.json b/test-servers/configs/structured-output-http.json new file mode 100644 index 00000000..48b9ae93 --- /dev/null +++ b/test-servers/configs/structured-output-http.json @@ -0,0 +1,15 @@ +{ + "serverInfo": { + "name": "structured-output-showcase", + "version": "1.0.0" + }, + "tools": [ + { "preset": "list_items" }, + { "preset": "get_temp" }, + { "preset": "echo" } + ], + "transport": { + "type": "streamable-http", + "port": 6601 + } +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 6c7e6679..806769b3 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -34,6 +34,7 @@ import { createGetAnnotatedMessageTool, createGetTempTool, createGetTempExtraTool, + createListItemsTool, createAddResourceTool, createRemoveResourceTool, createAddToolTool, @@ -158,6 +159,8 @@ function resolveToolPreset( return createGetTempTool(); case "get_temp_extra": return createGetTempExtraTool(); + case "list_items": + return createListItemsTool(); case "add_resource": return createAddResourceTool(); case "remove_resource": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index a87311ef..5a05b07b 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1048,6 +1048,48 @@ export function createGetTempTool(): ToolDefinition { }; } +/** Output schema for list_items: a nested list of items plus a total. */ +const ListItemsOutputSchema = z.object({ + items: z + .array( + z.object({ + id: z.number().describe("Item id"), + name: z.string().describe("Item name"), + tags: z.array(z.string()).describe("Item tags"), + }), + ) + .describe("The items"), + total: z.number().describe("Number of items"), +}); + +/** + * Create a "list_items" tool returning a short `content` summary alongside a + * DEEPLY NESTED `structuredContent` (objects inside arrays inside an object) — + * the shape from #1908, where the structured payload carries everything the + * text block only summarizes. Exercises the Structured Output section of the + * Tools screen result panel, which `get_temp`'s flat three-key object does not. + */ +export function createListItemsTool(): ToolDefinition { + return { + name: "list_items", + description: "Returns a list of items with nested structured output", + inputSchema: {}, + outputSchema: ListItemsOutputSchema, + handler: async () => { + const items = [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ]; + return { + content: [ + { type: "text" as const, text: `Found ${items.length} items.` }, + ], + structuredContent: { items, total: items.length }, + }; + }, + }; +} + /** * Create a "get_temp_extra" tool that declares the same output schema as * get_temp but returns an EXTRA, undeclared property in structuredContent. From e132d551e6bcacd69de8982e89385ee529d1d8a3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:26:17 -0400 Subject: [PATCH 079/941] docs: record where a dependency is declared, and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the maintenance rules, a change to the dependency layout has to land in the docs with it. Adds the placement rule in three places, at three altitudes: - README (Setup): the rule plus the concrete drift it prevents. - AGENTS.md: a "Dependency placement" section — the MCP SDK packages are root only, the v1 SDK is not ours and must not become a dependency, and anything reached solely through root-owned code with no manifest (`test-servers/src`, `core/`) is a root devDependency aliased to the repo root in `vitest.shared.mts`. - .github/copilot-instructions.md: the distilled, reviewer-citable form, so a diff re-adding one of these to a client manifest is flagged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 6 ++++++ AGENTS.md | 8 ++++++++ README.md | 2 ++ 3 files changed, 16 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aa1d6dd1..ccf73747 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,6 +72,12 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - `src/types/` is only for ambient `.d.ts` module stubs — not a home for new domain types. - ⚠️ The coverage `include` is a **whitelist** naming `components` / `hooks` / `theme` / `lib` / `utils` / `server` (plus the `core/*` runtime). A module placed outside those directories silently falls out of the ≥90% gate. Flag new top-level files or new grab-bag directories. +## Dependency placement + +- **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. +- **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. +- Dependencies used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. + ## Tests and the coverage gate - **All new or modified code needs tests.** The per-file gate is **≥ 90% on all four dimensions** — lines, statements, functions, **and branches** — enforced in CI for `clients/{web,cli,tui,launcher}` and the gated `core/` runtime. diff --git a/AGENTS.md b/AGENTS.md index a4185baa..164baff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,14 @@ v2 is **not** an npm workspace — each client under `clients/*` keeps its own ` After installing, `npm run build` builds all clients. The launcher scripts (`npm run web` / `web:dev`) run the built launcher, so build first; for day-to-day web iteration use `cd clients/web && npm run dev`. +## Dependency placement + +**The MCP SDK packages — `@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps` — are declared in the repo-root `package.json` and nowhere else.** Do not add them to a `clients/*/package.json`: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a *second copy* that drifts from the root's. That drift is not theoretical — it put two versions of `ext-apps` (1.7.4 / 1.7.5), and of the transitive v1 `@modelcontextprotocol/sdk` (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of `client`/`core` is exactly the failure the `dedupe` + `server.deps.inline` workaround in `vitest.shared.mts` exists for. + +The same rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `<client>/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. + +The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. + ## Contributing External contributions are accepted as **issues, not pull requests** — maintainers handle design and implementation through a prompt-driven workflow. diff --git a/README.md b/README.md index 13792764..8ae07537 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ npm install # root install; postinstall cascades into every client The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it. +**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. + ## Running during development For day-to-day web iteration, run Vite directly from the web client (fast HMR, no launcher build needed): From 3173b264f7cb3f407e142f13a56bb2007016e8de Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:34:41 -0400 Subject: [PATCH 080/941] fix(docker): publish on loopback by default; make a mounted state dir writable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1964. - README's Docker recipes dropped v1's `127.0.0.1:` prefix, so `-p 6274:6274` published the Inspector on every host interface. The container's `HOST=0.0.0.0` governs the container's interfaces, not the host's, so `DANGEROUSLY_BIND_ALL_INTERFACES` never covered this. Verified against a real container: from a LAN peer, `GET /` served the injected `MCP_INSPECTOR_API_TOKEN`, and that token then unlocked `/api/*` with no `Origin` header (server.ts:241 allows origin-less requests). Restore the prefix on both recipes, on the port-remap examples, and in the web README. - Adding a server failed inside the container the moment you mounted a volume to keep it. Docker seeds a named volume's ownership from the image's directory at the mount point, and creates it `root:root` when that directory is absent — so the non-root `node` user got `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. Create the dir in the image, owned by `node`, and document the volume recipe (plus the bind-mount `--user` caveat) — without a volume the catalog lives in the writable layer and `--rm` throws it away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- Dockerfile | 9 +++++++++ README.md | 18 +++++++++++++++--- clients/web/README.md | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 986231a0..f87a32ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,15 @@ EXPOSE 6274 # (core/storage/store-io.ts), so set it explicitly to the node user's writable # home and work from there. ENV HOME=/home/node + +# Create the runtime-state dir up front, owned by `node`. Docker seeds a named +# volume's ownership from the image's directory at the mount point — and when +# that directory does not exist it creates it `root:root`, which the non-root +# `node` user then cannot write. Without this, `-v inspector-data:/home/node/.mcp-inspector` +# (the recipe for keeping your saved servers across `docker run --rm`) makes +# every "add server" fail with `EACCES ... open '/home/node/.mcp-inspector/mcp.json.tmp-*'`. +RUN mkdir -p /home/node/.mcp-inspector && chown -R node:node /home/node/.mcp-inspector + USER node WORKDIR /home/node diff --git a/README.md b/README.md index 13792764..d000f5ad 100644 --- a/README.md +++ b/README.md @@ -319,14 +319,26 @@ A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector` ```bash # run the web UI (reads the auth token from the container logs) -docker run --rm -p 6274:6274 ghcr.io/modelcontextprotocol/inspector +docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector # or build the image locally docker build -t mcp-inspector . -docker run --rm -p 6274:6274 mcp-inspector +docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector ``` -The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). +**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publish wider only deliberately, and pair it with a known `MCP_INSPECTOR_API_TOKEN`. + +**Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 \ + -v mcp-inspector-data:/home/node/.mcp-inspector \ + ghcr.io/modelcontextprotocol/inspector +``` + +The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. + +The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 127.0.0.1:8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 127.0.0.1:8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` diff --git a/clients/web/README.md b/clients/web/README.md index c835aaf2..a3facd92 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -172,7 +172,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe - **Bind a specific address.** `HOST=192.168.1.50` (a LAN IP) or a public IP works directly: the default origin allow-list follows the bind host, so `allowedOrigins` becomes `http://<that-host>:PORT` and a browser hitting that address is accepted with no extra config. (The host is canonicalized the way a browser is — so `HOST=127.1` advertises `http://127.0.0.1:PORT`, an IPv6 bind host is bracketed as `http://[2001:db8::1]:PORT`, and the port is dropped when it's the http default `:80` — matching what the browser sends.) - **Behind TLS or a reverse proxy**, the browser's `Origin` becomes the public origin (e.g. `https://inspector.example.com`, often without a port), which won't match the auto-derived `http://<bind-host>:PORT`. Set `ALLOWED_ORIGINS` to the real public origin(s): `ALLOWED_ORIGINS=https://inspector.example.com`. -- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`. +- **Using the `0.0.0.0` wildcard** (opt-in via `DANGEROUSLY_BIND_ALL_INTERFACES=true`, as the Docker image does): a wildcard bind also serves loopback, so the default allow-list is the loopback trio plus the canonical wildcard origins (`http://0.0.0.0:PORT`, `http://[::]:PORT`), and **local access works out of the box** — `docker run -p 127.0.0.1:6274:6274` browsed at `http://localhost:6274` connects with no extra config. Reaching it at a **non-loopback** address (a LAN IP, a public hostname) still needs `ALLOWED_ORIGINS` — but since that **replaces** the default, keep the loopback forms in the list if you also browse locally: `ALLOWED_ORIGINS=http://localhost:PORT,http://127.0.0.1:PORT,http://192.168.1.50:PORT,https://inspector.example.com`. The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. From 5a3a3436ea05e9b26d7728266c61515d5500d220 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 09:35:04 -0400 Subject: [PATCH 081/941] docs: correct the dependency-classification claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2, four findings, all factual errors in the docs added by the previous commit: - `yaml` is in the root `dependencies`, not `devDependencies`, and its only importer is `test-servers/src/load-config.ts` — not `core/` or the root tooling, as the `vitest.shared.mts` comment claimed. - The rule as written told a reader to put anything reached through root-owned code — `core/` included — in `devDependencies`. That is wrong and would break the published package silently: the client builds externalize npm packages, so a published install resolves them from the root manifest, where devDependencies are absent. Placement (which manifest) and classification (which section) are now stated as separate questions, the second following from who consumes the package at runtime. `yaml`'s runtime classification is left alone and called out instead: moving it changes what ships, which is not a docs change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 --- .github/copilot-instructions.md | 3 ++- AGENTS.md | 8 +++++++- README.md | 2 +- vitest.shared.mts | 20 ++++++++++---------- 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccf73747..8f049824 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -76,7 +76,8 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. -- Dependencies used only by **root-owned code with no manifest** (`test-servers/src`, `core/`) go in the root `devDependencies` and are aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 164baff8..31d0fbed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,13 @@ After installing, `npm run build` builds all clients. The launcher scripts (`npm **The MCP SDK packages — `@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps` — are declared in the repo-root `package.json` and nowhere else.** Do not add them to a `clients/*/package.json`: Node resolution walks up, so the root install already serves every client, and a per-client declaration installs a *second copy* that drifts from the root's. That drift is not theoretical — it put two versions of `ext-apps` (1.7.4 / 1.7.5), and of the transitive v1 `@modelcontextprotocol/sdk` (1.29.0 / 1.30.0), in the tree at once (#1970), and a second copy of `client`/`core` is exactly the failure the `dedupe` + `server.deps.inline` workaround in `vitest.shared.mts` exists for. -The same rule covers anything reached only through **root-owned code that has no manifest of its own**: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, and `vitest.shared.mts` aliases both to the **repo root** rather than to `<client>/node_modules` like its other pins. If you add a dependency to `test-servers/src` or `core/`, declare it at the root and alias it there too. +The same **placement** rule covers anything reached only through **root-owned code that has no manifest of its own** (`test-servers/src`, `core/`): declare it at the root, and alias it to the **repo root** in `vitest.shared.mts` rather than to `<client>/node_modules` like that file's other pins. `express` and `yaml` are the two today — both reached through `test-servers/src` (express by the http/oauth servers, yaml by `load-config.ts`). + +**Placement and classification are separate questions.** Which *section* of the root manifest it goes in follows from who consumes it at runtime: + +- A package `core/` imports at runtime belongs in root **`dependencies`**. The client builds bundle `core/` but externalize npm packages, so a published install resolves them from the root manifest — and devDependencies are not installed for consumers, so a runtime import parked there breaks the published package while passing every local check. +- A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. +- `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. diff --git a/README.md b/README.md index 8ae07537..1237e541 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ npm install # root install; postinstall cascades into every client The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it. -**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same holds for anything used only by root-owned code with no manifest of its own: `express` (imported by `test-servers/src`) and `yaml` are root devDependencies, resolved from the root by `vitest.shared.mts`. +**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`. ## Running during development diff --git a/vitest.shared.mts b/vitest.shared.mts index f52919cc..1ebbc96a 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -83,16 +83,16 @@ export function vitestSharedPaths(clientDir: string) { replacement: path.resolve(dirname, "node_modules/@napi-rs/keyring"), }, // `express` and `yaml` resolve from the **repo root**, unlike every pin - // above. Both are reached only through root-owned code that has no manifest - // of its own — `test-servers/src` imports express, and `core`/the root - // tooling uses yaml — so the root is where they are declared and the only - // place a client's resolution chain is guaranteed to find them. Pointing - // these at `<client>/node_modules` is what broke when the MCP packages moved - // to the root (#1970): express was never declared by a client at all, it - // arrived in `clients/cli` as a peer of `express-rate-limit` under - // `@modelcontextprotocol/server-legacy`, so removing that manifest entry - // took express with it and every cli test that spawns a test server failed - // to resolve it. + // above. Both are reached only through `test-servers/src` — express by the + // http/oauth servers, yaml by `load-config.ts` — which is root-owned code + // with no manifest of its own, so the root is where they are declared and + // the only place a client's resolution chain is guaranteed to find them. + // Pointing these at `<client>/node_modules` is what broke when the MCP + // packages moved to the root (#1970): express was never declared by a client + // at all, it arrived in `clients/cli` as a peer of `express-rate-limit` + // under `@modelcontextprotocol/server-legacy`, so removing that manifest + // entry took express with it and every cli test that spawns a test server + // failed to resolve it. { find: /^express$/, replacement: path.resolve(repoRoot, "node_modules/express"), From 24a978633736cf1492d7e2fc53d1a8e332343798 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:02:59 -0400 Subject: [PATCH 082/941] fix(web): reset a number field's draft text when the form changes entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. The draft/value re-sync compares parsed numbers, so it cannot see a reset to an *equal* value. `ToolDetailPanel` is not keyed by tool, so its fields are reused across selections: type `-` (draft `"-"`, value `undefined`), switch to a tool with a same-named number field and no default, and the value is `undefined` on both sides — no divergence is detected and the stale `-` stays in the box for the new tool. Add a `resetKey` prop carrying the identity of whatever the form edits, and vary the number field's React key with it so no in-progress text can outlive the entity it was typed into. `ToolDetailPanel` passes the tool name; nested object forms inherit it. The schema object is no substitute — callers rebuild it every render, so its identity is unstable. The regression test drives the switch with `fireEvent` rather than `user.click`: a real click also blurs the input, and Mantine sanitizes an incomplete value on blur, which masked the defect entirely. A paired test asserts a *stable* `resetKey` does not remount, since remounting on every render would wipe the draft and reinstate #1888. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../groups/SchemaForm/SchemaForm.test.tsx | 76 ++++++++++++++++++- .../groups/SchemaForm/SchemaForm.tsx | 31 +++++++- .../ToolDetailPanel/ToolDetailPanel.tsx | 4 + 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index a381add1..32263e9a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2,7 +2,11 @@ import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; -import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + fireEvent, + renderWithMantine, + screen, +} from "../../../test/renderWithMantine"; import { SchemaForm } from "./SchemaForm"; describe("SchemaForm", () => { @@ -332,6 +336,76 @@ describe("SchemaForm", () => { expect(onChange).toHaveBeenLastCalledWith({ count: 15 }); }); + it("drops in-progress text when resetKey moves the form to another entity", async () => { + const user = userEvent.setup(); + // Both "tools" expose the same-named number field with no default, so the + // value is `undefined` before and after the switch. The value comparison + // sees no divergence, and only resetKey can tell the field to start over. + const schema: InspectorFormSchema = { + type: "object", + properties: { divisor: { type: "number", title: "Divisor" } }, + }; + function Harness() { + const [tool, setTool] = useState("tool-a"); + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <> + <button + type="button" + onClick={() => { + setTool("tool-b"); + // What ToolsScreen does on select: replace the form values. + setValues({}); + }} + > + Switch tool + </button> + <SchemaForm + schema={schema} + values={values} + onChange={setValues} + resetKey={tool} + /> + </> + ); + } + renderWithMantine(<Harness />); + const input = () => screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input(), "-"); + expect(input().value).toBe("-"); + // fireEvent, not user.click: a real click also blurs the input, and + // Mantine sanitizes an incomplete value on blur — which would mask + // whether the switch itself cleared the draft. This drives the state + // change without the blur, isolating the reset to resetKey. + fireEvent.click(screen.getByRole("button", { name: "Switch tool" })); + expect(input().value).toBe(""); + }); + + it("keeps in-progress text across re-renders of the same entity", async () => { + const user = userEvent.setup(); + // The counterpart to the test above: a stable resetKey must NOT remount + // the field, or every keystroke would wipe the draft and reinstate #1888. + const schema: InspectorFormSchema = { + type: "object", + properties: { divisor: { type: "number", title: "Divisor" } }, + }; + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm + schema={schema} + values={values} + onChange={setValues} + resetKey="tool-a" + /> + ); + } + renderWithMantine(<Harness />); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "1.5"); + expect(input.value).toBe("1.5"); + }); + it("re-syncs the displayed text when the value changes externally", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 0e67d854..efcb32e3 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -98,6 +98,11 @@ interface SchemaNumberInputProps { * parses to, which leaves an external reset (a cleared form, a loaded example) * working while an in-progress `"1."` — whose parse is `1`, matching the value we * just emitted — is left alone. + * + * That value comparison cannot see a reset to an *equal* value, so the caller is + * additionally expected to vary this component's React key via `SchemaForm`'s + * `resetKey` when it switches which entity the form edits. See the note on that + * prop for the case it covers. */ function SchemaNumberInput({ value, @@ -129,6 +134,25 @@ export interface SchemaFormProps { values: Record<string, unknown>; onChange: (values: Record<string, unknown>) => void; disabled?: boolean; + /** + * Stable identity of whatever this form is editing — a tool name, a request + * id. Pass it whenever the same mounted form is reused for a *different* + * entity, which is the case for the Tools tab: `ToolDetailPanel` is not keyed + * by tool, so selecting another tool re-renders the same field components. + * + * It exists because the number field's draft/value re-sync compares parsed + * numbers, and so cannot detect a reset to an equal value. Type `-` (draft + * `"-"`, value `undefined`), then switch to a tool with a same-named number + * field and no default: the value is `undefined` on both sides, no divergence + * is seen, and the stale `-` would otherwise be left in the box for the new + * tool to continue from. Varying `resetKey` remounts the field instead, so no + * in-progress text can outlive the entity it was typed into. + * + * Omit it when the form is mounted fresh per entity (the elicitation panels), + * where unmounting already discards the draft. The schema object itself is no + * substitute — callers rebuild it every render, so its identity is unstable. + */ + resetKey?: string; } function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { @@ -153,6 +177,7 @@ export function SchemaForm({ values, onChange, disabled = false, + resetKey, }: SchemaFormProps) { const properties = schema.properties ?? {}; const requiredFields = schema.required ?? []; @@ -232,7 +257,9 @@ export function SchemaForm({ if (fieldSchema.type === "number" || fieldSchema.type === "integer") { return ( <SchemaNumberInput - key={fieldName} + // The only field holding local state, so the only one that has to be + // remounted when `resetKey` says the form moved to another entity. + key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} label={label} description={description} withAsterisk={isRequired} @@ -318,6 +345,8 @@ export function SchemaForm({ handleFieldChange(fieldName, nestedValues) } disabled={disabled} + // Sub-fields belong to the same entity, so they reset with it. + resetKey={resetKey} /> </IndentedStack> </Stack> diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index a74c63a9..fbb6d26d 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -317,6 +317,10 @@ export function ToolDetailPanel({ values={formValues} onChange={onFormChange} disabled={isExecuting} + // This panel is reused across tool selections rather than remounted, + // so the form needs the tool name to drop another tool's + // in-progress field text. See SchemaFormProps.resetKey. + resetKey={name} /> {progress && <ProgressDisplay params={progress} />} From 5be2b3e87b7779174aa6e813c06f3fc829498388 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:15:28 -0400 Subject: [PATCH 083/941] =?UTF-8?q?docs:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20volume=20upgrade=20path,=20and=20drop=20the=20token?= =?UTF-8?q?=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A named volume created by an image predating the ownership fix keeps its root ownership if it is non-empty, since Docker only applies the image directory's ownership to an *empty* volume. Verified both halves: an empty pre-existing volume repairs itself on the first run of the fixed image, while one holding a file stays root-owned and still EACCESes. Document the distinction and the one-shot `chown` repair (verified to restore writes). - "pair it with a known MCP_INSPECTOR_API_TOKEN" was bad advice for a wider publication: the same paragraph notes `GET /` discloses the token, so a custom one is harvested exactly as a generated one is. Point at a real access-control boundary instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SFXDQBEjvjCEBhmYkmw79a --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d000f5ad..a9562079 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ docker build -t mcp-inspector . docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector ``` -**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publish wider only deliberately, and pair it with a known `MCP_INSPECTOR_API_TOKEN`. +**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. **Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: @@ -338,6 +338,14 @@ docker run --rm -p 127.0.0.1:6274:6274 \ The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. +**Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: + +```bash +docker run --rm -u 0 --entrypoint chown \ + -v mcp-inspector-data:/data ghcr.io/modelcontextprotocol/inspector \ + -R node:node /data +``` + The image defaults to `--web` bound to `0.0.0.0:6274` with browser auto-open disabled; override the args to run another mode (`docker run --rm ghcr.io/modelcontextprotocol/inspector --cli …`). Pass `-e MCP_INSPECTOR_API_TOKEN=…` to set a known token (otherwise one is generated and printed in the logs), or `-e DANGEROUSLY_OMIT_AUTH=true` to disable auth. Binding `0.0.0.0` (all network interfaces) is refused by default outside a container — it exposes the process-spawning backend to the local network — so the image opts in explicitly with `DANGEROUSLY_BIND_ALL_INTERFACES=true` (already set in the `Dockerfile`); a bare `HOST=0.0.0.0` without that flag exits with an error. If you **remap the published port** (`-p 127.0.0.1:8080:6274`), the browser's origin (`http://localhost:8080`) no longer matches the in-container port, so set `-e ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 127.0.0.1:8080:8080`) or connects will 403. `ALLOWED_ORIGINS` **replaces** the default list rather than merging, so list every loopback form you'll browse from (see the [web README](./clients/web/README.md#host-binding--the-origin-allow-list)). The image runs as the non-root `node` user and has a `HEALTHCHECK` that probes the web UI — it assumes the default `--web` mode, so add `--no-healthcheck` when running `--cli`/`--tui` (which have no web server). ## Contributing — `AGENTS.md` and `CLAUDE.md` From 6fd03d6de4442a841648836280f74f970ff4d491 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:22:05 -0400 Subject: [PATCH 084/941] fix(web): don't round unrepresentable numbers, and reset app-switch drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 2. Three findings, all real. 1. Precision. Mantine's `NumberInput` returns the raw *string* rather than a number once the value reaches `Number.MAX_SAFE_INTEGER` (see its `isValidNumber` guard), precisely because JS cannot hold it. Parsing that turned `90071992547409910` into `90071992547409904` and would have sent the server a number the user never typed. An inspector must not misreport what it transmits, so such input now reports no value — which is also what this field did before #1888, so nothing regresses. Only the integer part overflows, so long decimals stay parsed. 2. `AppDetailPanel` has the same reuse pattern as `ToolDetailPanel`: `AppsScreen.handleSelect` swaps `selectedAppName` + `formValues` in place without remounting, so switching between apps with a same-named number field could retain an equal-valued draft. It now passes `resetKey` too. 3. An exponent was never reachable: `NumberInput` masks input through `NumericFormat`, which rejects `e`. Dropped that claim from the doc comment rather than implying `"1e"` is a case being handled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0119LaPQ3v4NyBK3ZTdM9j84 --- .../AppDetailPanel/AppDetailPanel.test.tsx | 24 ++++++++++++++ .../groups/AppDetailPanel/AppDetailPanel.tsx | 6 ++++ .../groups/SchemaForm/SchemaForm.test.tsx | 28 +++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 31 ++++++++++++++++--- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx index 0c40d0ae..effbd5a2 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -149,4 +149,28 @@ describe("AppDetailPanel", () => { await user.click(screen.getByRole("button", { name: /open app/i })); expect(onOpenApp).toHaveBeenCalledTimes(1); }); + + it("drops a previous app's in-progress number text when the app changes", async () => { + const user = userEvent.setup(); + // AppsScreen swaps selectedAppName + formValues in place rather than + // remounting this panel, so two apps exposing a same-named number field + // share the field component. Both values here are undefined, which the + // draft/value re-sync cannot distinguish — only the resetKey identity can. + const numberFieldTool = (name: string): Tool => ({ + name, + title: name, + inputSchema: { + type: "object", + properties: { scale: { type: "number", title: "Scale" } }, + }, + }); + const { rerender } = renderWithMantine( + <AppDetailPanel {...baseProps} tool={numberFieldTool("app_a")} />, + ); + const input = () => screen.getByLabelText(/Scale/) as HTMLInputElement; + await user.type(input(), "-"); + expect(input().value).toBe("-"); + rerender(<AppDetailPanel {...baseProps} tool={numberFieldTool("app_b")} />); + expect(input().value).toBe(""); + }); }); diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 51bf14c6..8d5a2135 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -76,6 +76,12 @@ export function AppDetailPanel({ values={formValues} onChange={onFormChange} disabled={isOpening} + // Like ToolDetailPanel, this panel is reused across app selections + // rather than remounted (AppsScreen.handleSelect swaps + // selectedAppName + formValues in place), so the form needs the app + // tool's name to drop another app's in-progress field text. See + // SchemaFormProps.resetKey. + resetKey={tool.name} /> <OpenAppButton diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 32263e9a..5b9f064a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -336,6 +336,34 @@ describe("SchemaForm", () => { expect(onChange).toHaveBeenLastCalledWith({ count: 15 }); }); + it("reports no value for a magnitude JS cannot hold exactly", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + // Past Number.MAX_SAFE_INTEGER, Mantine stops emitting a number and hands + // back the raw string to avoid destroying precision. Number() would round + // this to ...904, so parsing it would send a value the user never typed. + await user.type(input, "90071992547409910"); + expect(input.value).toBe("90071992547409910"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: undefined }); + }); + + it("still parses a long decimal, which stays exactly representable", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + <ControlledSchemaForm schema={numberSchema} onChange={onChange} />, + ); + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + // Guards the safe-integer check against over-rejecting: the fractional + // digits are not what overflows, so this must not be dropped. + await user.type(input, "3.14159265358979"); + expect(onChange).toHaveBeenLastCalledWith({ divisor: 3.14159265358979 }); + }); + it("drops in-progress text when resetKey moves the form to another entity", async () => { const user = userEvent.setup(); // Both "tools" expose the same-named number field with no default, so the diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index efcb32e3..232b5327 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -53,10 +53,26 @@ function toEnumData( /** * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the - * field. It emits a `number` once the text parses cleanly, and the **raw string** - * while it does not — `""` when cleared, but also the in-progress `"1."`, `"-"`, - * and `"1e"`. Anything that is not a finite number becomes `undefined`, which is - * how an absent optional argument is represented everywhere else in this form. + * field. Anything that is not a finite number becomes `undefined`, which is how + * an absent optional argument is represented everywhere else in this form. + * + * `NumberInput` emits a `number` only when the text both parses *and* is exactly + * representable; otherwise it hands back the **raw string** (see its + * `isValidNumber` guard). Two quite different situations produce a string, and + * they are treated differently here: + * + * 1. **Mid-entry text** — `""` when cleared, plus `"1."`, `"1.50"`, and a lone + * `"-"`. These are parsed: `"1."` really does mean `1`. (Note that an + * exponent is *not* in this set — `NumberInput` masks input through + * `NumericFormat`, which rejects `e` outright, so `"1e"` can never be typed.) + * 2. **Values JS cannot hold exactly** — anything at or beyond + * `Number.MAX_SAFE_INTEGER`. `Number("90071992547409910")` silently yields + * `90071992547409904`, so parsing here would send the server a number the + * user never entered. An inspector must not misreport what it transmits, so + * these report no value instead — which is also what this field did with such + * input before #1888, making it no regression. Preserving them properly needs + * an exact-serialization path down the whole `tools/call` chain, which is a + * separate concern from being able to type a decimal. */ function toNumericValue(raw: string | number): number | undefined { if (typeof raw === "number") { @@ -66,7 +82,12 @@ function toNumericValue(raw: string | number): number | undefined { return undefined; } const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : undefined; + if (!Number.isFinite(parsed)) { + return undefined; + } + // Case 2 above. The integer part is what overflows exact representation; the + // fractional digits are bounded by the same guard and stay lossless. + return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined; } interface SchemaNumberInputProps { From 8049b4f7c8d3fb6cf2d6e9adf99aa6334c100f8a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 10:28:01 -0400 Subject: [PATCH 085/941] test: cover the structured-output cap and the list_items showcase end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from the Copilot review on #1975: - The `Large` story asserted nothing, so dropping the section's `mah` (or applying it to the wrong element) would still pass CI. Its play function now measures the real ScrollArea viewport: the visible height stays at the cap and the overflow is scrollable rather than clipped. - Nothing exercised the `list_items` fixture, so a preset-registration typo would leave the documented showcase broken with a green suite. A new integration test loads `structured-output-http.json` through loadConfig/resolveConfig — covering the file, the registry entry, and the fixture — connects, calls the tool, and asserts both the summary block and the nested structuredContent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt --- .../StructuredOutputPanel.stories.tsx | 19 +++++++ .../integration/mcp/inspectorClient.test.ts | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx index 443c78ab..c66760f0 100644 --- a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -59,10 +59,29 @@ export const Flat: Story = { }, }; +// A payload taller than the section's cap scrolls inside it rather than +// pushing the rest of the result panel down. Asserted on the real geometry, +// so dropping `mah` (or moving it to the wrong element) fails here. export const Large: Story = { args: { structuredContent: large, }, + play: async ({ canvasElement }) => { + await waitFor(() => + expect(canvasElement.textContent).toContain('"Row 60"'), + ); + const viewport = canvasElement.querySelector( + ".mantine-ScrollArea-viewport", + ); + if (!(viewport instanceof HTMLElement)) { + throw new Error("scroll viewport not found"); + } + // Capped: the visible height stays at the section's `mah`, well under the + // payload's natural height… + expect(viewport.clientHeight).toBeLessThanOrEqual(400); + // …and the overflow is reachable by scrolling rather than clipped away. + expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); + }, }; // Collapsing hides the payload; expanding brings it back. diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 7937b219..1a1645d8 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { resolve } from "node:path"; import * as z from "zod/v4"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { @@ -45,6 +46,8 @@ import { createAddResourceTool, createAddToolTool, createAddPromptTool, + loadConfig, + resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; import type { MessageEntry, @@ -1214,6 +1217,52 @@ describe("InspectorClient", () => { }); }); + describe("Structured output showcase config (#1908)", () => { + // Drives the shipped `structured-output-http.json` end to end — the file + // itself, the `list_items` preset registration, and the fixture's nested + // payload. A typo in any of the three fails here rather than leaving the + // documented showcase quietly broken. + const showcaseConfigPath = resolve( + import.meta.dirname, + "../../../../../../test-servers/configs/structured-output-http.json", + ); + + it("serves list_items with a summary block and a nested structuredContent", async () => { + server = createTestServerHttp( + resolveConfig(loadConfig(showcaseConfigPath)), + ); + await server.start(); + + client = new InspectorClient( + { type: "streamable-http", url: server.url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + + const tool = await getTool(client, "list_items"); + expect(tool.outputSchema).toBeDefined(); + + const result = await client.callTool(tool, {}); + expect(result.success).toBe(true); + + // The text block only summarizes — the payload is the structured half. + const content = result.result!.content as Array<{ + type: string; + text?: string; + }>; + expect(content[0].type).toBe("text"); + expect(content[0].text).toBe("Found 2 items."); + + expect(result.result!.structuredContent).toEqual({ + items: [ + { id: 1, name: "Item A", tags: ["foo", "bar"] }, + { id: 2, name: "Item B", tags: ["baz"] }, + ], + total: 2, + }); + }); + }); + describe("Default metadata (server-wide _meta)", () => { function metaOf(req: { message: unknown }): Record<string, unknown> { const params = (req.message as { params?: { _meta?: unknown } }).params; From df399272f2ad1452dfa0f713e7b7408bced93096 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:10:17 +0000 Subject: [PATCH 086/941] build(deps): bump the github-actions group across 1 directory with 5 updates Bumps the github-actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6` | `7` | | [actions/setup-node](https://github.com/actions/setup-node) | `6` | `7` | | [actions/cache](https://github.com/actions/cache) | `4` | `6` | | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` | | [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` | Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v6...v7) Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `actions/cache` from 4 to 6 - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) Updates `docker/setup-qemu-action` from 3 to 4 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4) Updates `docker/build-push-action` from 6 to 7 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/setup-qemu-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> --- .github/workflows/main.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1763cba..f7ddb49a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' @@ -79,7 +79,7 @@ jobs: # smoke reuse the cache instead of downloading its own copy. The Storybook # step below reuses the same install. - name: Cache Playwright browsers - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} @@ -129,10 +129,10 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'npm' @@ -217,7 +217,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Log in to the Container registry uses: docker/login-action@v4 @@ -241,14 +241,14 @@ jobs: latest=true - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build and push Docker image id: push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . push: true From 29e3ce95b826ac13ef34ef348ad4e3924661f94c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 12:21:14 -0400 Subject: [PATCH 087/941] chore(smoke): run the web smokes against a throwaway catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `startProdWebServer` spread `process.env` without setting `MCP_CATALOG_PATH`, so `smoke:web`, `smoke:web:browser`, and `smoke:web:app` all ran against the developer's real `~/.mcp-inspector/mcp.json` — unlike `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. That made the web smokes both destructive and non-deterministic. `smoke:web:app`'s deep link persists a `deep-link` server row, so a second run finds it already on disk, racing hydration: the deep-link effect misses it in `servers`, POSTs `addServer` anyway, and the backend answers 409. The app swallows that 409 by design (App.tsx:2842) so the smoke still passed, but it reported a spurious non-fatal console error that was really just residue from the previous run. Give the helper its own temp catalog dir and clean it up in `stop()` via `removeSafe`, so cleanup can never turn a passing smoke red. Isolation lives in the shared helper rather than per script, since every web smoke needs it. Only the catalog is redirected — other per-user state under `~/.mcp-inspector` stays shared, since isolating it means redirecting HOME wholesale, which would also move the npx and Playwright caches these smokes depend on. Verified: `smoke:web:app` twice in a row is now clean both times (previously the second run reported the 409), the real catalog's mtime is unchanged, and no temp dirs leak. CI never saw this — a fresh HOME per run made every CI run look like a first run. Closes #1977 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 46 ++++++++++++++++++++++++++++----- scripts/smoke-web-app.mjs | 7 ++++- scripts/smoke-web-browser.mjs | 7 ++++- scripts/smoke-web.mjs | 7 ++++- 4 files changed, 58 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 254410ef..2991d588 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -1,10 +1,12 @@ /** * Shared boot/readiness helper for the prod web smokes. * - * Both `scripts/smoke-web.mjs` (serves-the-HTML check) and - * `scripts/smoke-web-browser.mjs` (runs-the-bundle check, #1615) boot the *same* - * prod `mcp-inspector --web` server, so the spawn + readiness-poll boilerplate - * lives here once instead of being copy-pasted (and drifting) in each script. + * `scripts/smoke-web.mjs` (serves-the-HTML check), `scripts/smoke-web-browser.mjs` + * (runs-the-bundle check, #1615), and `scripts/smoke-web-app.mjs` (MCP Apps + * end-to-end, #1859) all boot the *same* prod `mcp-inspector --web` server, so the + * spawn + readiness-poll boilerplate lives here once instead of being copy-pasted + * (and drifting) in each script. Catalog isolation (#1977) lives here for the same + * reason — it is a property every web smoke needs, not one script's concern. * * Repo-root paths are derived from import.meta.url, so a caller's cwd (e.g. * `smoke:web:browser` does `cd clients/web` first so its `npx playwright @@ -12,9 +14,13 @@ */ import { spawn } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; + +import { removeSafe } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); @@ -24,20 +30,43 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. * + * The server always runs against a **throwaway catalog**, never the developer's + * real `~/.mcp-inspector/mcp.json` (#1977). Without `MCP_CATALOG_PATH` the web + * backend falls back to that default writable catalog, which made these smokes + * both destructive and non-deterministic: `smoke:web:app`'s deep link persists a + * `deep-link` server row, so a second run finds it already on disk. The row is + * then racing hydration — the deep-link effect misses it in `servers`, POSTs + * `addServer` anyway, and the backend answers 409. The app swallows that 409 by + * design so the smoke still passes, but it reports a spurious non-fatal console + * error that is really just residue from the previous run. Isolating the catalog + * makes every run look like the first one, and mirrors `smoke:cli` / `smoke:tui`, + * which have always driven a temp `--catalog`. + * + * Only the catalog is redirected. Other per-user state under `~/.mcp-inspector` + * (OAuth tokens, the `storage/` dir) is still shared — isolating that means + * redirecting HOME wholesale, which these smokes deliberately do not do, since + * HOME also resolves the npx and Playwright caches they depend on. + * * @param {object} opts * @param {string} opts.host * @param {string} opts.port * @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN + * @param {string} [opts.label] prefix for the temp dir + cleanup warnings */ -export function startProdWebServer({ host, port, token }) { +export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; + // The file need not exist — the backend seeds an empty catalog on first run. + const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + const catalogPath = join(catalogDir, "catalog.json"); + const child = spawn(process.execPath, [launcherEntry, "--web"], { env: { ...process.env, CLIENT_PORT: port, HOST: host, MCP_INSPECTOR_API_TOKEN: token, + MCP_CATALOG_PATH: catalogPath, // Don't pop a browser in CI. MCP_AUTO_OPEN_ENABLED: "false", }, @@ -119,10 +148,15 @@ export function startProdWebServer({ host, port, token }) { return { baseUrl, + catalogPath, waitForReady, whenChildExits, stop: () => { if (!exited) child.kill("SIGTERM"); + // SIGTERM is not awaited, so the server may still hold the catalog for a + // moment. `removeSafe` warns rather than throws, so the worst case is a + // leaked temp dir the OS reclaims — never a red smoke over cleanup. + removeSafe(catalogDir, { label }); }, }; } diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 73ca5f03..dce98805 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -104,7 +104,12 @@ let mcpUrl = null; let mcpServer = null; let browser = null; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:app", +}); async function shutdown() { if (browser) { diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 1188865c..dbc90844 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -78,7 +78,12 @@ const TOKEN = "smoke-web-browser-token"; // the font/CDN flake. const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:browser", +}); let browser = null; async function shutdown() { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 143ba925..2fbcfb44 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -30,7 +30,12 @@ const TOKEN = "smoke-web-token"; // literal because this plain .mjs script can't import the TS source. const TOKEN_GLOBAL = "__INSPECTOR_API_TOKEN__"; -const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN }); +const server = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web", +}); function fail(message) { console.error(`smoke:web FAILED — ${message}`); From 16c96d44c33d876df1bc6badbd426909a299fdae Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:25:09 -0400 Subject: [PATCH 088/941] fix(smoke): await the server's exit before removing its catalog, and test it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both Copilot review comments on #1978. 1. `stop()` removed the catalog dir immediately after a bare `child.kill()`, which only *delivers* SIGTERM. That is precisely the #1801 race `child-cleanup.mjs:11-17` documents, and it says both halves are needed: `stopChild()` closes the race on the normal path, `removeSafe()` makes the residual case harmless. I had used only the second. `smoke-web-app.mjs` already used `stopChild` for its MCP test-server child, so the helper was inconsistent with its own sibling. `stop()` is now async — it awaits `stopChild` and then removes — and all three callers await it (`smoke-web.mjs`'s `fail()` became async to match, so every call site had to `await fail(...)` or execution would run past it instead of exiting). 2. The isolation contract had no test. The smokes exit the process right after teardown, so a regression reintroducing the shared catalog — or dropping cleanup — would leave them all green. Split the two pure pieces out (`createTempCatalog`, `buildWebServerEnv`) and cover them in a sibling `prod-web-server.test.mjs`: a unique dir per run, no pre-created catalog file, the dir is removable, and an inherited `MCP_CATALOG_PATH` (or `MCP_AUTO_OPEN_ENABLED`) cannot beat the explicit one — the spread-then-assign ordering that guard depends on. `startProdWebServer` itself stays untested by design; it spawns a real launcher, which is the smokes' job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 84 ++++++++++++++++++----- scripts/lib/prod-web-server.test.mjs | 99 ++++++++++++++++++++++++++++ scripts/smoke-web-app.mjs | 2 +- scripts/smoke-web-browser.mjs | 2 +- scripts/smoke-web.mjs | 17 +++-- 5 files changed, 177 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/prod-web-server.test.mjs diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 2991d588..6365a155 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -20,12 +20,64 @@ import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -import { removeSafe } from "./child-cleanup.mjs"; +import { removeSafe, stopChild } from "./child-cleanup.mjs"; const libDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(libDir, "..", ".."); const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); +/** + * Mint a fresh throwaway catalog for one server run. + * + * `mkdtemp` (not a fixed name) is what makes concurrent runs safe: the three web + * smokes run back-to-back today, but a fixed path would have them share — and + * silently reintroduce the cross-run bleed this whole change removes. + * + * The catalog *file* is deliberately not created. The backend seeds an empty + * catalog on first use, which is exactly the first-run state we want every run + * to start from. + * + * @returns {{ dir: string, path: string }} + */ +export function createTempCatalog() { + const dir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); + return { dir, path: join(dir, "catalog.json") }; +} + +/** + * Build the child env for the prod web server. + * + * Split out from the spawn so the isolation contract is unit-testable without + * starting a server (#1977). The ordering is load-bearing: `MCP_CATALOG_PATH` + * is assigned *after* the `process.env` spread, so an inherited value from the + * developer's shell is overridden rather than silently winning — which would + * put the smoke straight back on whatever catalog that variable names. + * + * @param {object} opts + * @param {string} opts.host + * @param {string} opts.port + * @param {string} opts.token + * @param {string} opts.catalogPath + * @param {NodeJS.ProcessEnv} [opts.baseEnv] + */ +export function buildWebServerEnv({ + host, + port, + token, + catalogPath, + baseEnv = process.env, +}) { + return { + ...baseEnv, + CLIENT_PORT: port, + HOST: host, + MCP_INSPECTOR_API_TOKEN: token, + MCP_CATALOG_PATH: catalogPath, + // Don't pop a browser in CI. + MCP_AUTO_OPEN_ENABLED: "false", + }; +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. @@ -56,20 +108,10 @@ const launcherEntry = resolve(repoRoot, "clients/launcher/build/index.js"); export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; - // The file need not exist — the backend seeds an empty catalog on first run. - const catalogDir = mkdtempSync(join(tmpdir(), "smoke-web-catalog-")); - const catalogPath = join(catalogDir, "catalog.json"); + const { dir: catalogDir, path: catalogPath } = createTempCatalog(); const child = spawn(process.execPath, [launcherEntry, "--web"], { - env: { - ...process.env, - CLIENT_PORT: port, - HOST: host, - MCP_INSPECTOR_API_TOKEN: token, - MCP_CATALOG_PATH: catalogPath, - // Don't pop a browser in CI. - MCP_AUTO_OPEN_ENABLED: "false", - }, + env: buildWebServerEnv({ host, port, token, catalogPath }), stdio: ["ignore", "inherit", "inherit"], }); @@ -151,11 +193,17 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - stop: () => { - if (!exited) child.kill("SIGTERM"); - // SIGTERM is not awaited, so the server may still hold the catalog for a - // moment. `removeSafe` warns rather than throws, so the worst case is a - // leaked temp dir the OS reclaims — never a red smoke over cleanup. + /** + * Terminate the server, then remove its catalog dir. **Await this** — the + * two halves are the documented pair in `child-cleanup.mjs`, and both are + * needed. `stopChild` closes the #1801 race on the normal path (a bare + * `kill()` only *delivers* the signal, so a synchronous remove can hit + * ENOTEMPTY when the server writes the catalog on its way out); `removeSafe` + * then makes the residual case harmless, warning instead of throwing so a + * leftover temp dir can never turn a passing smoke red. + */ + stop: async () => { + await stopChild(child, { label, what: "prod web server" }); removeSafe(catalogDir, { label }); }, }; diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs new file mode 100644 index 00000000..bcf65f25 --- /dev/null +++ b/scripts/lib/prod-web-server.test.mjs @@ -0,0 +1,99 @@ +/** + * Unit tests for the prod-web-server helper's catalog-isolation contract (#1977). + * + * The smokes themselves cannot guard this: they exercise startup and then exit + * the process immediately after teardown, so a regression that reintroduced the + * shared catalog — or that stopped cleaning up — would still leave every smoke + * green. These lock down the three properties that make isolation real: each run + * gets its own catalog, an inherited `MCP_CATALOG_PATH` cannot win, and the temp + * dir is removable on stop. + * + * `startProdWebServer` itself is not unit-tested — it spawns a real launcher, so + * its contract is the smokes' job. What is tested here is exactly the part that + * decides *which catalog the server sees*, which is pure. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs"; +import { removeSafe } from "./child-cleanup.mjs"; + +const BASE = { + host: "127.0.0.1", + port: "6299", + token: "test-token", + catalogPath: "/tmp/example/catalog.json", +}; + +test("createTempCatalog: mints a unique dir per call", () => { + const a = createTempCatalog(); + const b = createTempCatalog(); + try { + assert.notEqual(a.dir, b.dir, "two runs must not share a catalog dir"); + assert.equal(dirname(a.path), a.dir); + assert.ok(existsSync(a.dir)); + assert.ok(existsSync(b.dir)); + } finally { + removeSafe(a.dir); + removeSafe(b.dir); + } +}); + +test("createTempCatalog: does not create the catalog file itself", () => { + // The backend seeds an empty catalog on first use; pre-creating the file would + // hand it a zero-byte file to parse instead of the clean first-run state. + const { dir, path } = createTempCatalog(); + try { + assert.equal(existsSync(path), false); + } finally { + removeSafe(dir); + } +}); + +test("createTempCatalog: its dir is removable by removeSafe", () => { + const { dir, path } = createTempCatalog(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, "{}"); + assert.equal(removeSafe(dir), true); + assert.equal(existsSync(dir), false); +}); + +test("buildWebServerEnv: points the server at the given catalog", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: overrides an inherited MCP_CATALOG_PATH", () => { + // The regression this guards: assigning before the spread would let a + // developer's exported MCP_CATALOG_PATH win, putting the smoke back on a + // real catalog while every test still passed. + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_CATALOG_PATH: "/home/dev/.mcp-inspector/mcp.json" }, + }); + assert.equal(env.MCP_CATALOG_PATH, BASE.catalogPath); +}); + +test("buildWebServerEnv: passes through unrelated inherited vars", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: { PATH: "/usr/bin" } }); + assert.equal(env.PATH, "/usr/bin"); +}); + +test("buildWebServerEnv: sets host, port, token, and disables auto-open", () => { + const env = buildWebServerEnv({ ...BASE, baseEnv: {} }); + assert.equal(env.HOST, BASE.host); + assert.equal(env.CLIENT_PORT, BASE.port); + assert.equal(env.MCP_INSPECTOR_API_TOKEN, BASE.token); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); + +test("buildWebServerEnv: an inherited auto-open setting cannot re-enable it", () => { + const env = buildWebServerEnv({ + ...BASE, + baseEnv: { MCP_AUTO_OPEN_ENABLED: "true" }, + }); + assert.equal(env.MCP_AUTO_OPEN_ENABLED, "false"); +}); diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index dce98805..c5f2f8a6 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -120,7 +120,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); if (mcpServer) { const child = mcpServer; mcpServer = null; diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index dbc90844..ade88c54 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -95,7 +95,7 @@ async function shutdown() { } browser = null; } - server.stop(); + await server.stop(); } async function fail(message) { diff --git a/scripts/smoke-web.mjs b/scripts/smoke-web.mjs index 2fbcfb44..867ae4ff 100644 --- a/scripts/smoke-web.mjs +++ b/scripts/smoke-web.mjs @@ -37,31 +37,34 @@ const server = startProdWebServer({ label: "smoke:web", }); -function fail(message) { +// Async because `server.stop()` awaits the child's exit before removing its temp +// catalog — so every call site must `await fail(...)`, or execution would run on +// past it instead of exiting. +async function fail(message) { console.error(`smoke:web FAILED — ${message}`); - server.stop(); + await server.stop(); process.exit(1); } try { const res = await server.waitForReady(); if (res.status !== 200) { - fail(`GET / returned HTTP ${res.status}, expected 200`); + await fail(`GET / returned HTTP ${res.status}, expected 200`); } const body = await res.text(); if (!body.includes(TOKEN_GLOBAL)) { - fail( + await fail( `served HTML is missing the ${TOKEN_GLOBAL} global (token not injected)`, ); } if (!body.includes(TOKEN)) { - fail("served HTML is missing the injected auth-token value"); + await fail("served HTML is missing the injected auth-token value"); } console.log( `smoke:web OK — GET / => 200 with injected ${TOKEN_GLOBAL} at ${server.baseUrl}`, ); - server.stop(); + await server.stop(); process.exit(0); } catch (err) { - fail(err instanceof Error ? err.message : String(err)); + await fail(err instanceof Error ? err.message : String(err)); } From 4a993fc45a93f74ce1b423d9f1ad355fd83c0a36 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:32:20 -0400 Subject: [PATCH 089/941] docs(smoke): document the throwaway-catalog invariant and fix the label JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two review comments that still applied to HEAD (the third, the stop() race, was already fixed in a8022138). - AGENTS.md still described the prod-web-server helper as shared by only smoke:web and smoke:web:browser, and said nothing about catalog isolation. It is the source of truth for these smoke mechanics, so record the invariant there: the temp catalog per run and why, that only the catalog (not HOME) is redirected, that teardown needs both halves of child-cleanup.mjs and is therefore async for every caller, and where the contract is unit-tested. - The `label` JSDoc claimed the option prefixes the temp dir. It does not — that prefix is fixed at `smoke-web-catalog-` (deliberately, so every run's dir is greppable); `label` only prefixes teardown warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 4 +++- scripts/lib/prod-web-server.mjs | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 829442c8..7a864308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -657,7 +657,9 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. -- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with `smoke:web:browser`, so the two can't drift. +- `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. + + **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract's pure halves — `createTempCatalog` and `buildWebServerEnv` — are unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). - `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 6365a155..708933ee 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -103,7 +103,8 @@ export function buildWebServerEnv({ * @param {string} opts.host * @param {string} opts.port * @param {string} opts.token value injected as MCP_INSPECTOR_API_TOKEN - * @param {string} [opts.label] prefix for the temp dir + cleanup warnings + * @param {string} [opts.label] prefix for teardown warnings (the temp dir's own + * prefix is fixed, so every run's dir is greppable as `smoke-web-catalog-*`) */ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { const baseUrl = `http://${host}:${port}`; From 3d541388db763bd655097ef4b91fc34d40743db0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 14:51:12 -0400 Subject: [PATCH 090/941] test(smoke): exercise the real teardown path, not just removeSafe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot was right that the previous test proved only that `removeSafe` can delete a directory — it never drove `stop()`, so deleting the cleanup call would have left the unit suite AND all three smokes green. That is exactly the regression these tests exist to catch, and they did not catch it. Extract the teardown into an exported `teardownWebServer({ child, catalogDir, label })`, which `stop()` now delegates to, and drive it against a stand-in child process (a real `node -e setInterval` that stays alive until signalled) rather than a mock. Three cases: - it removes the catalog dir it was given (asserted on the dir itself, not a spy, so the assertion cannot drift from the behavior); - it awaits the child's exit before removing — the #1801 race in one assertion, since losing the await is otherwise invisible; - an already-dead child is not an error, which is the failure path a smoke takes when it calls fail() after the launcher has crashed. Mutation-checked: with the `removeSafe` call deleted, 2 of the 3 now fail (previously: 0). AGENTS.md updated to match. Signed-off-by is now on every commit (#1978 DCO); the requirement itself is undocumented and tracked in #1979. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- scripts/lib/prod-web-server.mjs | 45 ++++++++++++++------ scripts/lib/prod-web-server.test.mjs | 62 +++++++++++++++++++++++++--- 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7a864308..5f9e44bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -659,7 +659,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. - `smoke:web` (`scripts/smoke-web.mjs`) starts `mcp-inspector --web` (prod, no `--dev`) against the built `clients/web/dist` and asserts `GET /` serves the SPA (HTTP 200) with the injected `__INSPECTOR_API_TOKEN__`. Prod `--web` serves from `clients/web/dist`, which ships in the published package but is absent in a fresh checkout — the runner builds it on demand (`build:client` = `vite build`) on first launch, or exits with an actionable error if that build can't run (see `clients/web/server/ensure-web-build.ts` and the launcher README). `--dev` runs Vite directly and never needs `dist`. It shares the spawn/readiness/teardown helper (`scripts/lib/prod-web-server.mjs`) with **`smoke:web:browser` and `smoke:web:app`**, so the three can't drift. - **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract's pure halves — `createTempCatalog` and `buildWebServerEnv` — are unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog. + **Every web smoke runs against a throwaway catalog (#1977).** The helper mints a temp dir per run and passes it as `MCP_CATALOG_PATH`; without it the web backend falls back to the developer's real `~/.mcp-inspector/mcp.json`, which made these smokes both destructive and non-deterministic — `smoke:web:app`'s deep link persists a `deep-link` server row, so a *second* run found it already on disk, raced hydration, and drew a spurious (swallowed, non-fatal) 409 that was really just residue from the previous run. CI never saw it: a fresh `HOME` per run made every CI run look like a first run. This matches `smoke:cli` / `smoke:tui`, which have always driven a temp `--catalog`. Only the **catalog** is redirected — other per-user state under `~/.mcp-inspector` (OAuth tokens, `storage/`) stays shared, because isolating it means redirecting `HOME` wholesale, which would also move the npx and Playwright caches these smokes depend on. Teardown uses **both** halves of `scripts/lib/child-cleanup.mjs` (`stopChild` to await the child's exit, then `removeSafe` to delete the dir) — a bare `kill()` only *delivers* the signal, so removing synchronously re-enters the #1801 ENOTEMPTY race. That makes `stop()` **async**, so every caller must `await` it (and a caller's own `fail()`/`shutdown()` becomes async in turn, or execution runs past the intended exit). The isolation contract is unit-tested in `scripts/lib/prod-web-server.test.mjs` via `test:scripts`, since the smokes exit immediately after teardown and so cannot detect a regression that silently reshared the catalog or stopped cleaning up: `createTempCatalog` and `buildWebServerEnv` cover *which* catalog the server gets, and `teardownWebServer` — extracted from `stop()` for exactly this reason — is driven against a stand-in child process so the teardown asserts on the real directory rather than a spy. - `smoke:web:browser` (`scripts/smoke-web-browser.mjs`, #1615) goes a step further than `smoke:web`: it boots the same prod `--web` server and then actually **runs** the bundle in headless Chromium (Playwright — already a `clients/web` devDependency for the Storybook tests), asserting the app renders its first meaningful frame (the "Add Servers" control) with **no uncaught error**. `smoke:web` only checks the served HTML, so a Node built-in reaching the browser bundle slipped through it; this smoke catches that regression as a _class_ (e.g. #1612). The mechanism is the uncaught error, not a magic string: under Vite the excluded module becomes an empty stub and the first _call_ into it (e.g. `fs.readFileSync(...)` during a transitive module's init) throws a `TypeError` that aborts app mount. A _synchronous_ such throw fires `pageerror`; its _async_ twin (the same `TypeError` via `await`/`.then()`, or a failed dynamic import) is logged on the console channel as `Uncaught (in promise) …` / `Failed to fetch dynamically imported module` — the smoke hard-fails on both. The literal `Module "…" has been externalized` text is, **in a prod build**, a build-time warning (`vite build` / `npm run build`), not a runtime message, so the browser never sees it (under `npm run dev` Vite's stub is instead a `Proxy` that `console.warn`s that string at runtime); and an externalized import that is never _called_ ships a harmless `{}` and is invisible here by design. Every _other_ console error is printed as a diagnostic, not a failure (so a benign font-CDN or React-warning `console.error` doesn't flake CI). Playwright is resolved via `createRequire` based at `clients/web/package.json` — a bare `import("playwright")` would resolve relative to `scripts/`, not the cwd, so it can't be reached that way (it only appears to work when an ancestor `node_modules` carries playwright, and fails in CI, which has none). The npm script's `cd clients/web` exists only so `npx playwright install chromium` finds the local playwright bin (a no-op when already installed). - `smoke:web:app` (`scripts/smoke-web-app.mjs`, #1859) goes one step further again: `smoke:web:browser` stops at first paint and never connects to a server, so the Apps tab, the sandbox controller, and the UI-protocol bridge were unexercised by any smoke. This one boots the same prod `--web` server, spawns the `mcp-app-http.json` composable test server (the `mcp_app_demo` tool + its `mcp_app_demo_widget` UI resource), and drives the whole **connect → open app → widget ready** chain through a single deep-link navigate (`?serverUrl=…&autoConnect=<token>&openApp=…&appArgs=…&autoOpen=<token>`). The assertion is the `data-app-status="ready"` contract from [clients/web/README.md](clients/web/README.md) — the renderer reports `ready` only once the widget has loaded inside the sandbox iframe _and_ fired `notifications/initialized` back through the bridge, so one attribute covers the sandbox proxy being served, the UI resource loading, and the handshake completing. Two mechanics are load-bearing and easy to get wrong: the test server announces readiness on **stderr** (`console.error` in `server-composable.ts`), so both child streams are piped and scanned — watching stdout alone times out with an empty diagnostic; and its bound port is **not** the config's, because `createTestServerHttp` resolves through `findAvailablePort()`, which walks upward when the configured port is taken — so the smoke parses the announced URL rather than assuming `3130`. **Scope note:** this runs against the repo build tree like every other smoke, so it would _not_ have caught #1859 itself (a packaging failure — the file is always present in-repo); `pack:verify` owns that dimension. It does carry a cheap structural pre-check that the proxy page exists at the path `sandbox-controller.ts` resolves, so a move/rename fails fast with a clear cause instead of an opaque render timeout. - **The build gate for the browser-externalized-builtin class (#1769)** is the earlier, more complete companion to `smoke:web:browser`. A Vite plugin in `clients/web/vite.config.ts` (logic in `clients/web/server/browser-externalized-builtin-gate.ts`, unit-tested) turns Vite 8's _browser-externalization warning_ (`Module "node:*" has been externalized for browser compatibility`) into a hard `vite build` error, so a Node built-in in the browser graph now **fails `npm run build` / `validate`** instead of shipping a `{}` stub. This catches **both** the _called-at-init_ case (which `smoke:web:browser` also catches, but later/at runtime) **and** the _imported-but-never-called_ case (the `{}` stub that is invisible to the runtime smoke "by design" — see above). Because rolldown **swallows a throw inside `onLog`** (the one hook where a thrown error doesn't abort — verified against vite@8.0.0), the plugin _records_ the warning in `onLog` and re-throws in `buildEnd`. There is **no stable log `code`**, so the gate keys off the documented message phrasing; `npm run verify:build-gate` (`scripts/verify-build-gate.mjs`, in `npm run ci` and the GitHub workflow) runs a real build with a `node:fs` probe forced into `src/main.tsx` and asserts the build fails via the gate — the only check that catches the message phrasing **drifting** in a future Vite bump and silently disabling the gate. The gate is scoped to `vite build` (`apply: 'build'`) — never `vite dev` or the vitest projects — **and** to the browser (`client`) environment (`applyToEnvironment`), so a future SSR/node environment built from this config isn't failed for a legitimate `node:*` import; the Node runner build (tsup, `build:runner`) is a separate config where built-ins are legitimate. `smoke:web:browser` stays as the runtime backstop for crashes the build can't reason about. diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 708933ee..44986c2c 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -78,6 +78,36 @@ export function buildWebServerEnv({ }; } +/** + * Terminate a spawned web server, then remove its catalog dir. + * + * The two halves are the documented pair in `child-cleanup.mjs`, and both are + * needed. `stopChild` closes the #1801 race on the normal path (a bare `kill()` + * only *delivers* the signal, so a synchronous remove can hit ENOTEMPTY when the + * server writes the catalog on its way out); `removeSafe` then makes the + * residual case harmless, warning instead of throwing so a leftover temp dir can + * never turn a passing smoke red. + * + * Exported separately from `startProdWebServer` so the teardown *contract* is + * testable against a stand-in child, without booting a real launcher. Testing it + * matters more than it looks: deleting the `removeSafe` call would leave both the + * unit suite and all three smokes green (the smokes exit immediately after + * teardown), which is precisely the regression the tests exist to catch. + * + * @param {object} opts + * @param {import("node:child_process").ChildProcess} opts.child + * @param {string} opts.catalogDir + * @param {string} [opts.label] + */ +export async function teardownWebServer({ + child, + catalogDir, + label = "smoke:web", +}) { + await stopChild(child, { label, what: "prod web server" }); + removeSafe(catalogDir, { label }); +} + /** * Spawn `mcp-inspector --web` (prod, no `--dev`) against the built * `clients/web/dist` and return handles for readiness + teardown. @@ -194,18 +224,7 @@ export function startProdWebServer({ host, port, token, label = "smoke:web" }) { catalogPath, waitForReady, whenChildExits, - /** - * Terminate the server, then remove its catalog dir. **Await this** — the - * two halves are the documented pair in `child-cleanup.mjs`, and both are - * needed. `stopChild` closes the #1801 race on the normal path (a bare - * `kill()` only *delivers* the signal, so a synchronous remove can hit - * ENOTEMPTY when the server writes the catalog on its way out); `removeSafe` - * then makes the residual case harmless, warning instead of throwing so a - * leftover temp dir can never turn a passing smoke red. - */ - stop: async () => { - await stopChild(child, { label, what: "prod web server" }); - removeSafe(catalogDir, { label }); - }, + /** Terminate the server, then remove its catalog dir. **Await this.** */ + stop: () => teardownWebServer({ child, catalogDir, label }), }; } diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs index bcf65f25..d3f46671 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -15,11 +15,17 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { existsSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; -import { buildWebServerEnv, createTempCatalog } from "./prod-web-server.mjs"; -import { removeSafe } from "./child-cleanup.mjs"; +import { + buildWebServerEnv, + createTempCatalog, + teardownWebServer, +} from "./prod-web-server.mjs"; +import { hasExited, removeSafe } from "./child-cleanup.mjs"; const BASE = { host: "127.0.0.1", @@ -53,11 +59,55 @@ test("createTempCatalog: does not create the catalog file itself", () => { } }); -test("createTempCatalog: its dir is removable by removeSafe", () => { +/** + * A stand-in for the web server: a real child process that ignores nothing and + * simply stays alive until signalled, so `teardownWebServer` exercises its true + * SIGTERM→exit path rather than a mock's idea of one. + */ +function spawnIdleChild() { + return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", + }); +} + +test("teardownWebServer: removes the catalog dir it was given", async () => { + // The regression this guards: dropping the `removeSafe` call from the teardown + // would leave every smoke green, since they exit right after teardown and so + // never observe the leak. Assert on the dir itself, not on a spy. + const child = spawnIdleChild(); const { dir, path } = createTempCatalog(); - mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, "{}"); - assert.equal(removeSafe(dir), true); + assert.ok(existsSync(dir)); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); +}); + +test("teardownWebServer: waits for the child to exit before removing", async () => { + // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a + // teardown that removed synchronously could unlink the dir while the server was + // still writing to it. If teardown resolves with the child still alive, the + // await on stopChild has been lost. + const child = spawnIdleChild(); + const { dir } = createTempCatalog(); + assert.equal(hasExited(child), false, "child should start alive"); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + + assert.ok(hasExited(child), "teardown must await the child's exit"); +}); + +test("teardownWebServer: an already-dead child is not an error", async () => { + // The failure path: a smoke calls fail() after the launcher already crashed. + // Teardown still has to clean up rather than hang on an exit that never comes. + const child = spawnIdleChild(); + child.kill("SIGKILL"); + await once(child, "exit"); + const { dir } = createTempCatalog(); + + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + assert.equal(existsSync(dir), false); }); From 678184ed3e94aa0df9509ac9c87015c52273ded7 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:17:56 -0400 Subject: [PATCH 091/941] fix(smoke): pin the teardown ordering, and correct a docblock the test refuted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both round-3 review points, both mine. 1. The docblock claimed deleting `removeSafe` would leave "both the unit suite and all three smokes green" — written before the test existed and never updated, so it contradicted the test added in the same PR. Only the smokes would stay green; the unit tests are precisely what fails on it. 2. `assert.ok(hasExited(child))` after the await proved only that the child was dead by the time teardown returned. It passes just as happily if the catalog were removed *first*, or while an un-awaited `stopChild` was still pending — so it did not lock down the #1801 ordering it claimed to. Sample the directory from the child's own `exit` handler instead, registered before teardown so it runs ahead of stopChild's listener and observes the world at the instant the child dies. `removeSafe` cannot have run by then: it is sequenced after the promise stopChild resolves from that very event. Mutation-checked against both defects the old assertion missed — removing before stopping, and dropping the await. Each now fails the test; neither did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/prod-web-server.mjs | 9 +++++---- scripts/lib/prod-web-server.test.mjs | 24 +++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 44986c2c..895d9509 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -89,10 +89,11 @@ export function buildWebServerEnv({ * never turn a passing smoke red. * * Exported separately from `startProdWebServer` so the teardown *contract* is - * testable against a stand-in child, without booting a real launcher. Testing it - * matters more than it looks: deleting the `removeSafe` call would leave both the - * unit suite and all three smokes green (the smokes exit immediately after - * teardown), which is precisely the regression the tests exist to catch. + * testable against a stand-in child, without booting a real launcher. That split + * is what makes the leak detectable at all: the smokes exit immediately after + * teardown, so deleting the `removeSafe` call below would leave all three of them + * green. The focused unit tests in `prod-web-server.test.mjs` are the only thing + * that fails on it. * * @param {object} opts * @param {import("node:child_process").ChildProcess} opts.child diff --git a/scripts/lib/prod-web-server.test.mjs b/scripts/lib/prod-web-server.test.mjs index d3f46671..0888d887 100644 --- a/scripts/lib/prod-web-server.test.mjs +++ b/scripts/lib/prod-web-server.test.mjs @@ -84,18 +84,32 @@ test("teardownWebServer: removes the catalog dir it was given", async () => { assert.equal(existsSync(dir), false, "teardown must remove the catalog dir"); }); -test("teardownWebServer: waits for the child to exit before removing", async () => { - // The #1801 race in one assertion: a bare kill() only *delivers* SIGTERM, so a - // teardown that removed synchronously could unlink the dir while the server was - // still writing to it. If teardown resolves with the child still alive, the - // await on stopChild has been lost. +test("teardownWebServer: the catalog outlives the child", async () => { + // The #1801 ordering, pinned at the one instant that distinguishes it. Simply + // asserting `hasExited` after the await is too weak — that also passes if the + // dir were removed *first*, or while an un-awaited stopChild was still pending. + // So sample the dir from the child's own `exit` handler: registering it before + // teardown puts it ahead of stopChild's listener, so it observes the world at + // the moment the child dies. removeSafe cannot have run yet — it is sequenced + // after the promise stopChild resolves from this very event. const child = spawnIdleChild(); const { dir } = createTempCatalog(); assert.equal(hasExited(child), false, "child should start alive"); + let dirAtChildExit = null; + child.once("exit", () => { + dirAtChildExit = existsSync(dir); + }); + await teardownWebServer({ child, catalogDir: dir, label: "test" }); + assert.equal( + dirAtChildExit, + true, + "catalog must still exist when the child exits — removing earlier is the #1801 race", + ); assert.ok(hasExited(child), "teardown must await the child's exit"); + assert.equal(existsSync(dir), false, "and must remove it afterwards"); }); test("teardownWebServer: an already-dead child is not an error", async () => { From bb599e5057d89e96a46b0d22faf454e821bda9bf Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:28:30 -0400 Subject: [PATCH 092/941] =?UTF-8?q?docs:=20add=20the=20six-month=20Inspect?= =?UTF-8?q?or=20roadmap=20(Aug=202026=20=E2=86=92=20Feb=202027)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Through v1 the Inspector was a follow-along project — the spec moved, we chased it, and whatever planning capacity was left went to keeping up rather than to the tool's own design. v2 meeting the 2026-07-28 spec across all three clients lifts that constraint, but nothing written down said so, and there was no shared list of what the MCP roadmap is likely to demand of us next. Add docs/inspector-roadmap-2026-h2.md covering 2026-08-11 → 2027-02-11 (~26 weekly milestones, v2.2.0 → ~v2.27.0), in two explicitly-budgeted tracks. Track A predicts Inspector features per MCP roadmap theme and WG charter, each tagged build-now / design-now-build-on-signal / watch, so a speculative item gets a liaison rather than code: transports and session lifecycle, Server Cards (SEP-2127), Tasks retry/expiry and the extension→core migration, enterprise audit + ID-JAG, triggers/events, result types, interceptors (SEP-1763), file inputs (SEP-2356), skills (SEP-2640), primitive grouping, and conformance. Track B is the work we choose, headlined by the zoomable protocol/network timeline — lanes, spans rather than points, brush-to-filter, MRTR and task grouping — then session record/replay, a shared diff primitive, a command palette, saved-call collections feeding CI assertions, the argument-editor workstream, and Connection Doctor. The sequencing argument is that several Track B items are force multipliers for Track A: each protocol feature arrives with a rendering problem, and one general timeline plus one general diff is cheaper than a bespoke panel per SEP. So the general surfaces come first and the SEP work renders into them. Two consolidations the survey surfaced, both worth acting on regardless of whether this plan is adopted as written: - #1853, #1856, #1885, #1928, #1919 and #1910 are one defect class, not six bugs — the argument editor is not schema-aware. Treating them separately has already produced one regression from v1.x (#1928). - #962, #1936, #1951, #1944 and #1914 are one missing feature: an ordered diagnostic that reports which connection step failed and what to do. Sourcing is the published roadmap plus the nine WG and six IG charters, cited in full at the end; the doc says so, and flags that Track A should be revised if internal planning carries themes the public page omits. Status is "Draft for WG review" — merging this starts the discussion rather than settling it. Adds one file and touches nothing else. The README guide list and the AGENTS.md docs/ tree entry are deliberately left for a follow-up rather than bundled here, to keep this diff to the document itself. Markdown-only, and outside every format and typecheck glob (SOURCE_EXTENSIONS has no "md"), so no gate is affected. Closes #1980 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CaLPZQzfWDHoPxyL1vbYL Signed-off-by: cliffhall <cliff@futurescale.com> --- docs/inspector-roadmap-2026-h2.md | 570 ++++++++++++++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/inspector-roadmap-2026-h2.md diff --git a/docs/inspector-roadmap-2026-h2.md b/docs/inspector-roadmap-2026-h2.md new file mode 100644 index 00000000..bfcee53d --- /dev/null +++ b/docs/inspector-roadmap-2026-h2.md @@ -0,0 +1,570 @@ +# Inspector Roadmap — August 2026 → February 2027 + +> A six-month plan for the Inspector client family (Web, CLI, TUI), covering both +> **spec-following work** driven by the MCP roadmap and **experience work** we choose +> for ourselves. + +**Horizon:** 2026-08-11 → 2027-02-11 (~26 weekly milestones, `v2.2.0` → ~`v2.27.0`) +**Owner:** [Inspector V2 WG](https://modelcontextprotocol.io/community/working-groups/inspector-v2) +**Status:** Draft for WG review + +--- + +## Table of Contents + +- [1. Why this document exists](#1-why-this-document-exists) +- [2. The two tracks](#2-the-two-tracks) +- [3. Track A — following the spec](#3-track-a--following-the-spec) + - [3.1 Transport evolution and scalability](#31-transport-evolution-and-scalability) + - [3.2 Server Cards](#32-server-cards) + - [3.3 Agent communication and Tasks](#33-agent-communication-and-tasks) + - [3.4 Enterprise readiness](#34-enterprise-readiness) + - [3.5 Triggers and events](#35-triggers-and-events) + - [3.6 Result type improvements](#36-result-type-improvements) + - [3.7 Interceptors](#37-interceptors) + - [3.8 File uploads](#38-file-uploads) + - [3.9 Skills over MCP](#39-skills-over-mcp) + - [3.10 Primitive grouping and tool annotations](#310-primitive-grouping-and-tool-annotations) + - [3.11 Conformance and validation](#311-conformance-and-validation) +- [4. Track B — experience work we choose](#4-track-b--experience-work-we-choose) + - [4.1 The zoomable timeline (headline)](#41-the-zoomable-timeline-headline) + - [4.2 Session record, replay, and share](#42-session-record-replay-and-share) + - [4.3 Diff and compare](#43-diff-and-compare) + - [4.4 Command palette and global search](#44-command-palette-and-global-search) + - [4.5 Saved calls and collections](#45-saved-calls-and-collections) + - [4.6 Assertions and CI flows](#46-assertions-and-ci-flows) + - [4.7 The argument editor workstream](#47-the-argument-editor-workstream) + - [4.8 Connection Doctor](#48-connection-doctor) + - [4.9 Server management and portability](#49-server-management-and-portability) + - [4.10 Workspace and layout](#410-workspace-and-layout) + - [4.11 Performance at scale](#411-performance-at-scale) + - [4.12 Accessibility and keyboard-first operation](#412-accessibility-and-keyboard-first-operation) + - [4.13 Onboarding](#413-onboarding) + - [4.14 Plugin architecture](#414-plugin-architecture) +- [5. Sequencing](#5-sequencing) +- [6. What we are deliberately not doing](#6-what-we-are-deliberately-not-doing) +- [7. Open questions](#7-open-questions) +- [8. Sources](#8-sources) + +--- + +## 1. Why this document exists + +Through v1, the Inspector was a **follow-along project**. The spec moved, we chased it, and +whatever planning capacity remained went to keeping up rather than to the tool's own design. +Every release was reactive by necessity. + +That constraint has lifted. v2 meets the 2026-07-28 spec across all three clients, on SDK v2, +with a shared `core/`, a ≥90% per-file coverage gate, and a smoke/e2e apparatus that catches +packaging failures. For the first time we can spend planned effort on **what the Inspector +should be**, not only on what the spec just became. + +This document splits the next six months into those two kinds of work, so that neither +starves the other. The explicit intent is a **roughly even split of capacity** — spec-following +work is non-negotiable but bounded, and the remaining capacity is ours to direct. + +> **Sourcing note.** The MCP roadmap circulated as a Google Doc ("MCP Roadmap Process and +> Timeline") requires authentication and could not be read directly. This plan is built from +> the **published** roadmap at `modelcontextprotocol.io/development/roadmap` (last updated +> 2026-03-05) plus the current WG and IG charters, which together cover the same themes at +> more implementation-relevant detail. If the private doc contains timelines or themes absent +> from the public page, §3 should be revised against it before the plan is adopted. + +--- + +## 2. The two tracks + +| | **Track A — Spec-following** | **Track B — Experience** | +| --------------------------- | ----------------------------------------------------- | ------------------------------------------- | +| **Driver** | MCP roadmap, WG deliverables, SEP acceptance | Our own judgment about the tool | +| **Trigger to start** | A SEP reaches Draft with a Tier-1 SDK reference impl | Whenever we have capacity | +| **Risk** | Slips when upstream slips; we cannot control the date | We control the date entirely | +| **Failure mode if starved** | Inspector stops being the reference test client | Inspector stays a protocol dump, not a tool | +| **Target capacity** | ~50% | ~50% | + +The two tracks are not independent. Several Track B items — the timeline, session +record/replay, diff — are **force multipliers for Track A**: each new protocol feature +arrives with a rendering problem, and a general timeline plus a general diff is cheaper than +one bespoke panel per SEP. That is the core scheduling argument of this plan: **build the +general surfaces early so the spec work that lands later is cheap to display.** + +### How the Inspector's role is changing + +Worth stating plainly, because it shapes the priorities below. The roadmap's Validation +section names **conformance test suites**, **SDK tiers**, and **reference implementations** as +standing investments, and SEP-2484 now requires conformance tests for final SEPs. The +Inspector is the most visible MCP client in the ecosystem and is already the thing people +reach for when a server misbehaves. + +That points at an expanded role: not just _"show me the traffic"_ but _"tell me whether this +server is correct."_ Several items below (Server Card diffing, the conformance runner, +assertions, the readiness summary) are steps toward that, and they should be evaluated as a +group rather than individually. + +--- + +## 3. Track A — following the spec + +Each subsection states the upstream theme, our read on what it means for the Inspector, and a +concrete feature list. **Confidence** flags how much of the list we can commit to now: + +- 🟢 **Build now** — the shape is known; blocked only on our own capacity. +- 🟡 **Design now, build on signal** — enough detail to design against; wait for a Draft SEP or a Tier-1 SDK impl before building. +- 🔴 **Watch** — too early to predict a UI; keep a tracking issue and a WG liaison. + +### 3.1 Transport evolution and scalability + +**Upstream:** Transports WG. Next-generation Streamable HTTP that runs statelessly across +multiple instances and behaves correctly behind load balancers and proxies; a session model +covering creation, resumption, and migration; conformance guidance for SDK authors. The +roadmap is explicit that **no additional official transports** ship this cycle. + +**Read:** This is the theme most likely to produce breaking wire changes, and the one where +the Inspector is most useful — session resumption and proxy behavior are exactly the failures +nobody can reproduce by reading code. Our era model (`legacy` / `modern` / `auto`) already +gives us the negotiation seam to add a third era behind. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Session lifecycle lane** — session id, creation, resumption, migration, and expiry as first-class events, not log lines | 🟢 | Renders into the timeline (§4.1). Buildable against today's session model; extends to the new one. | +| **`Last-Event-ID` resumption support and display** | 🟢 | Existing gap — [#920](https://github.com/modelcontextprotocol/inspector/issues/920). Do it now; it is table stakes for the new session work. | +| **Proxy / intermediary harness** — route through a configurable proxy, then deliberately misbehave: rewrite headers, drop the GET stream, close mid-response | 🟡 | Builds on [#1684](https://github.com/modelcontextprotocol/inspector/issues/1684). Needs a `misbehaving-proxy` preset in `test-servers/`. | +| **Stateless-mode verification** — issue the same request across N synthetic instances and diff the responses | 🟡 | Directly tests the property the WG is specifying. Pairs with §4.3. | +| **Third protocol era behind the existing negotiation seam** | 🟡 | Cost is low _if_ we keep era-conditional exposure rather than replacing the legacy path. | +| **Custom transport support** | 🟢 | [#1741](https://github.com/modelcontextprotocol/inspector/issues/1741). The roadmap pushes experimentation to custom transports, so the Inspector should be able to load one. | + +### 3.2 Server Cards + +**Upstream:** Server Card WG, [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127) (Draft). A standard `.well-known` document exposing structured server metadata so browsers, crawlers, and registries can discover capabilities **without connecting**. Deliberately kept close to a subset of `server.json`. + +**Read:** This is the single highest-leverage Track A item for us, because it creates a new +Inspector capability rather than a new panel: **inspect before connect**. It also creates an +obvious correctness question that only a tool like ours can answer. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Card preview** — paste a URL, fetch the card, render the capability surface, one-click add to catalog | 🟡 | The pre-connection entry point. Wait for the format to settle. | +| **Card-vs-reality diff** — compare the advertised card against what `initialize` + `*/list` actually return | 🟡 | _The_ Inspector-shaped feature here. Nobody else in the ecosystem is positioned to check this. Shares machinery with [#1034](https://github.com/modelcontextprotocol/inspector/issues/1034) and §4.3. | +| **`mcp-inspector --card-lint <url>`** — validate a card, non-zero exit on drift | 🟡 | CI-usable; a natural companion to the conformance runner (§3.11). | +| **`server.json` support** | 🟢 | [#922](https://github.com/modelcontextprotocol/inspector/issues/922). Prerequisite — the card is a subset, so this lands first regardless. | + +### 3.3 Agent communication and Tasks + +**Upstream:** Agents WG. Tasks (`io.modelcontextprotocol/tasks`, SEP-2663) is being +**stabilized and promoted from an extension into core**. Named open gaps: **retry semantics** +(what happens on transient failure, who decides to retry) and **expiry policies** (result +retention, how clients learn a result expired). An Agents Extension is under evaluation. + +**Read:** We already drive the modern Tasks extension ourselves over a raw-wire channel, +because SDK v2 era-gates `tasks/*` out. Promotion to core will move that back under the SDK — +plan for the migration, but **keep the era-conditional exposure**; the legacy `capabilities.tasks` +path must keep working. + +| Feature | Confidence | Notes | +| -------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------- | +| **Retry visualization** — attempts, backoff, who initiated each retry | 🟡 | Design against the WG's gap list now. | +| **Expiry / TTL surfacing** — retention countdown on a completed task, distinct rendering for an expired-result error | 🟡 | Cheap once the semantics land; easy to get wrong if we guess early. | +| **Tasks as timeline spans** — a long-running task is a span, not a row | 🟢 | Falls out of §4.1 for free. The strongest argument for building the timeline first. | +| **Extension → core migration** | 🟡 | Retire the raw-wire channel when the SDK covers it; keep both paths during overlap. | +| **`Mcp-Name` header on Tasks over Streamable HTTP** | 🟢 | [#1917](https://github.com/modelcontextprotocol/inspector/issues/1917) — open bug, fix now. | +| **Discover checkmarks for task extensions** | 🟢 | [#1887](https://github.com/modelcontextprotocol/inspector/issues/1887). | + +### 3.4 Enterprise readiness + +**Upstream:** An Enterprise WG is expected to form. Four named areas: **audit trails and +observability**, **enterprise-managed auth** (Cross-App Access / ID-JAG), **gateway and proxy +patterns**, and **configuration portability**. Most output is expected as extensions rather +than core spec changes. Related: the Enterprise-Managed Authorization IG, and sponsored work +on [SEP-1932 (DPoP)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932) and [SEP-1933 (Workload Identity Federation)](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933). + +**Read:** "Audit trails and observability, in a form enterprises can feed into their existing +pipelines" is a description of something the Inspector nearly already has. We hold the entire +session; we simply cannot **export** it in any pipeline-shaped format. That gap is cheap to +close and disproportionately valuable. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **OTLP export** — emit the session as OpenTelemetry spans; show trace/span ids inline; "copy as trace" | 🟢 | SEP-414 already puts trace context in `_meta`. Buildable today, no upstream dependency. | +| **Structured audit transcript** — the full session as a stable, documented JSON artifact | 🟢 | Shares its format with §4.2 record/replay. Build once, use for both. | +| **Machine-readable readiness summary** | 🟢 | [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). | +| **ID-JAG / Cross-App Access test flow** | 🟡 | The EMA IG exists specifically because this only works when IdP + client + AS interoperate. A test client is exactly what they lack. Related: [#1937](https://github.com/modelcontextprotocol/inspector/issues/1937), [#571](https://github.com/modelcontextprotocol/inspector/issues/571). | +| **DPoP and Workload Identity Federation** | 🔴 | Both sponsored but pre-acceptance. Watch; do not build. | +| **Gateway mode** — declare an intermediary, then show what we sent vs. what the gateway forwarded | 🟡 | Depends on the Gateways IG settling propagation semantics. | +| **Configuration portability** | 🟢 | [#1912](https://github.com/modelcontextprotocol/inspector/issues/1912), [#904](https://github.com/modelcontextprotocol/inspector/issues/904), plus `server.json` (§3.2). | + +### 3.5 Triggers and events + +**Upstream:** Triggers and Events WG. A standardized server→client callback mechanism +(webhooks or similar), with subscription lifecycle and **ordering guarantees that hold across +all transports**. Status: "SEP: Events in MCP v1 RFC" — **Ideating**. + +**Read:** ⚠️ **This is the largest architectural change on the horizon for us, and the one we +are least prepared for.** Every Inspector surface today assumes we are the party that +_initiated_ the connection. A webhook mechanism makes us a **server** — we must host a +publicly reachable callback endpoint, which for a tool that usually runs on `localhost` is a +real problem (tunnels, port forwarding, or a relay). + +We should start the design conversation **now**, well ahead of the SEP, and bring it to the +WG as implementation feedback. The ordering-guarantee requirement in particular is +untestable without a client that records arrival order — which is us. + +| Feature | Confidence | Notes | +| ----------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Callback receiver** — backend-hosted endpoint, its URL registered as the trigger target | 🔴 | Needs design now, build later. Security review mandatory: an inbound public endpoint on a process that spawns subprocesses is a serious surface. | +| **Local reachability story** — tunnel integration or documented guidance | 🔴 | Likely the hardest UX problem of the whole six months. | +| **Delivery log with ordering and duplicate assertions** | 🔴 | The conformance value: did events arrive in the promised order? were any redelivered? | + +### 3.6 Result type improvements + +**Upstream:** "On the Horizon." **Streamed results** (incremental output for generated text, +audio, video frames) and **reference-based results** (client decides when to pull a large +payload into context). Explicitly cross-cutting — streaming touches transport, references +touch the schema. + +**Read:** Streaming changes how every result panel renders: today we display a _result_, and +we would need to display a _stream that becomes a result_. Worth a rendering abstraction +before the SEP, not after. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| **Incremental result rendering** — progressive display, with time-to-first-chunk and inter-chunk timing | 🔴 | The timing view is Inspector-shaped; the timeline is the natural home. | +| **Reference-result handling** — show a handle plus an explicit "pull payload", with size accounting | 🔴 | Also a good default for large payloads _today_, independent of the SEP (see §4.11). | + +### 3.7 Interceptors + +**Upstream:** Interceptors WG, [SEP-1763](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2076) (Draft). Interceptors as a new primitive with two types — **validators** (pass/fail) and **mutators** (transform payloads) — across in-process, sidecar, and remote deployment models, with priority-based chain ordering and audit-mode semantics. A **CLI client for interceptor invocation and testing** is a listed WG deliverable (Ideating, unowned). + +**Read:** Two things stand out. First, "CLI client for interceptor invocation and testing" is +**an unclaimed deliverable that describes our CLI**. Worth raising with the WG — Ola co-leads +both groups, so the liaison already exists. Second, an interceptor chain is a +_before → after payload transformation_, which is a diff, which we should already be able to +render (§4.3). + +| Feature | Confidence | Notes | +| ---------------------------------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------- | +| **Interceptor test bench** — register a chain, show before/after diff per hop, visualize priority ordering | 🟡 | The clearest "Inspector as the reference tool" opportunity of the six months. | +| **Audit-mode rendering** — what _would_ have been blocked or mutated | 🟡 | Follows the SEP's audit semantics. | +| **CLI interceptor invocation** | 🟡 | **Action: raise with the Interceptors WG.** If we take it, it needs its own milestone allocation. | +| **Our plugin architecture as an interceptor host** | 🟡 | [#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). Prevents us building two extension mechanisms. | + +### 3.8 File uploads + +**Upstream:** File Uploads WG, [SEP-2356](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2356) (Draft, TS SDK reference impl targeted End May). Declarative `FileInputDescriptor` on tool input schemas and elicitation schemas, so hosts render native file pickers. Success criteria explicitly include **"at least one production host rendering a native file picker from the descriptor."** + +**Read:** The most tractable Track A item on the list — narrow, well-specified, with a TS SDK +reference implementation coming, and we are a credible candidate for that "production host." +It touches three surfaces: `SchemaForm` (Tools), elicitation forms, and MCP Apps. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------ | ---------- | ------------------------------------------------ | +| **File picker in `SchemaForm`** when a descriptor is present, with data-URI encoding | 🟡 | Wait for the TS SDK types, then build. Low risk. | +| **Same in elicitation forms** | 🟡 | Shared component. | +| **Size guardrails and host-side validation** | 🟡 | The SEP references OWASP ASVS V5. | + +### 3.9 Skills over MCP + +**Upstream:** Skills Over MCP WG, [SEP-2640](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) (In Review, Extensions Track). Resources-based; a reference implementation is also In Review. + +**Read:** Because it is Resources-based, the incremental cost is low — a Skills view over the +existing resource machinery rather than a new subsystem. + +| Feature | Confidence | Notes | +| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------- | +| **Skills view** — list, preview content, show activation | 🟡 | Gate on the negotiated extension, the way the Tasks tab gates today. | + +### 3.10 Primitive grouping and tool annotations + +**Upstream:** Two IGs. **Primitive Grouping** explores organizing Tools/Resources/Prompts +beyond flat lists — deliberately not picking one canonical pattern early. **Tool Annotations** +is consolidating six independent annotation SEPs and considering runtime annotations and tool +_response_ annotations. + +**Read:** Grouping is the rare case where the spec-following work and the UX work are the same +work. Flat lists are already our weakest surface on large servers — [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) (duplicate tool names) was a symptom. **Build the grouped sidebar as a UX +improvement now**, and adopt whatever grouping the IG lands as a data source later. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------------------------------- | +| **Grouped / tree sidebars with group-aware search** | 🟢 | Build now on client-side heuristics (name prefixes, annotations). Ship value immediately; swap the data source later. | +| **Richer annotation rendering** | 🟢 | Extends the existing `AnnotationBadge`. | +| **Annotation-driven confirmation** before a `destructiveHint` call | 🟢 | Small, obviously correct, no upstream dependency. | +| **Runtime / response annotations** | 🔴 | Watch. | + +### 3.11 Conformance and validation + +**Upstream:** Standing investment — conformance test suites, SDK tiers ([SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730)), reference implementations. [SEP-2484](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2484) now **requires conformance tests for final SEPs**, and the EMA IG is explicitly contributing scenarios to the `modelcontextprotocol/conformance` repository. + +**Read:** A conformance suite needs a driver and a report. We are the natural driver, and we +already have a CLI that exits non-zero. This is the clearest path to the expanded role +described in §2 — and unlike most of Track A, **it is not gated on any SEP**. + +| Feature | Confidence | Notes | +| ------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Conformance runner** — run the suite against a connected server, render pass/fail per assertion | 🟡 | Needs coordination on the suite's programmatic interface. **Action: open a conversation with the conformance maintainers.** | +| **`mcp-inspector --conformance` for CI** | 🟡 | Same engine, CLI report, exit code. | +| **Strict schema validation with actionable errors** | 🟢 | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015). No dependency; start here. | + +--- + +## 4. Track B — experience work we choose + +Nothing in this section waits on a SEP. Ordered by leverage, not by effort. + +### 4.1 The zoomable timeline (headline) + +**Committed.** The single feature that most changes what the Inspector _is_. + +The Protocol and Network screens are chronological lists. A list answers "what happened next" +but not "what happened _at the same time_", "how long did this take", or "which of these +caused that" — and those are the questions people actually bring to the Inspector. A +session with an MRTR round-trip, a long-running task, a subscription stream, and a +mid-session OAuth step-up is, in list form, an interleaved mess. On a time axis it is legible +at a glance. + +**Design sketch:** + +- **A third view over the existing stores**, not a new data path. Protocol, Network, and + Timeline become three renderings of one session. This keeps the coverage gate and the + existing `protocolUtils` derivations intact. +- **Lanes**, each independently collapsible: + `client → server` · `server → client` · notifications · tasks · subscription streams · OAuth/auth · errors +- **Spans, not points.** A request occupies from send to response; a task occupies its whole + lifetime; a stream is a bar with events on it. Duration becomes visible, which is most of + the value. +- **Zoom and pan** across the full range, from whole-session down to sub-millisecond. + Brush-to-select a range and filter every other view to it. +- **Grouping** — an MRTR conversation is one collapsible span containing its rounds; a task + contains its polls. +- **Click through** to the existing Protocol/Network entry. The timeline is navigation, not a + replacement. +- **A pinned mini-timeline strip** above every tab, so a spike is visible while you are in + Tools, and clicking it jumps to the full view. +- **Latency distribution** as a secondary view — per method, so a slow tool is obvious. +- **Virtualized**, keyboard-navigable, and rendered from the same store the other views use. + +**Deliberately out of scope for v1 of this feature:** cross-server correlation (needs §4.10), +and OTLP-shaped nesting (needs §3.4). + +### 4.2 Session record, replay, and share + +Save a complete session — protocol log, network log, server config, negotiated capabilities — +to a single file. Reopen it later, on another machine, with no server running. Attach it to a +bug report. + +This changes issue triage from "works on my machine" into an artifact, and it is the same +serialization format as the enterprise audit transcript (§3.4) — **build the format once**. +Replay also gives us fixtures: a recorded session is a regression test. + +### 4.3 Diff and compare + +Two sessions, or two servers, side by side. Concretely: + +- **Capability diff** — reconnect after changing your server, see exactly what moved in + `tools/list` / `resources/list` / `prompts/list`. ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- **Session diff** — same calls, two servers, what differed. +- **Payload diff** — before/after for any pair of JSON documents. + +The payload differ is a **shared primitive**: interceptor before/after (§3.7), Server +Card-vs-reality (§3.2), and stateless-instance comparison (§3.1) are all the same widget with +different inputs. Build it as a component first, then wire the three consumers. + +### 4.4 Command palette and global search + +`⌘K` to jump to any server, tool, resource, or prompt; re-run the last call; switch tabs. Plus +full-text search across the protocol log with a real filter syntax (`method:tools/call +status:error duration:>500ms`). The Inspector is currently a mouse-driven app; for a developer +tool that is a daily tax. + +### 4.5 Saved calls and collections + +Name a tool call with its arguments, save it, re-run it, parameterize it, share it. A +Postman-collection model for MCP. The single most requested shape of workflow improvement for +any protocol client, and it composes directly with §4.6. + +### 4.6 Assertions and CI flows + +Attach expectations to a saved call — result matches schema, field equals value, latency under +a bound — and run the collection from the CLI with a non-zero exit on failure. This turns the +Inspector from an interactive tool into part of a server author's test suite, and it shares an +engine with the conformance runner (§3.11). +Related: [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1886](https://github.com/modelcontextprotocol/inspector/issues/1886), [#1916](https://github.com/modelcontextprotocol/inspector/issues/1916). + +### 4.7 The argument editor workstream + +Six open issues are all the same defect class — the argument editor is not schema-aware: + +| Issue | Symptom | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------- | +| [#1853](https://github.com/modelcontextprotocol/inspector/issues/1853) | JSON parameter editor escaping while typing | +| [#1856](https://github.com/modelcontextprotocol/inspector/issues/1856) | Backspace recursively escapes JSON tool inputs | +| [#1885](https://github.com/modelcontextprotocol/inspector/issues/1885) | Null values corrupted with cascading escapes | +| [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | Nullable enums fall back to a broken raw Textarea (v1.x regression) | +| [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | Resource templates lack RFC 6570 expansion | +| [#1910](https://github.com/modelcontextprotocol/inspector/issues/1910) | Complex `_meta` not expressible | + +**Fix them as one workstream, not six bugs.** A proper schema-aware editor (CodeMirror or +Monaco with JSON Schema integration) resolves the class and unblocks file inputs (§3.8) and +strict validation (§3.11). Treating them individually has already produced one regression from +v1. + +### 4.8 Connection Doctor + +Connection failures are currently opaque, and five open issues say so +([#962](https://github.com/modelcontextprotocol/inspector/issues/962), [#1936](https://github.com/modelcontextprotocol/inspector/issues/1936), [#1951](https://github.com/modelcontextprotocol/inspector/issues/1951), [#1944](https://github.com/modelcontextprotocol/inspector/issues/1944), [#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +Run an ordered checklist on failure — DNS · TCP · TLS (including local-cert cases) · +`/.well-known` discovery · protocol version negotiation · auth — and report **which step +failed and what to do about it**. First-connection success is the entire first impression of +the tool, and today a `https://localhost` server or a dev container silently fails. + +Bundle the related fixes: `*.localhost` domains ([#1944](https://github.com/modelcontextprotocol/inspector/issues/1944)), the trusted-local-host OAuth HTTP +exception ([#1911](https://github.com/modelcontextprotocol/inspector/issues/1911)), and the ghost-server entry left by a failed manual connect ([#1914](https://github.com/modelcontextprotocol/inspector/issues/1914)). + +### 4.9 Server management and portability + +Already well represented on the board; grouping it here so it is scheduled as a theme rather +than piecemeal: rich server configuration ([#1857](https://github.com/modelcontextprotocol/inspector/issues/1857)), custom headers and cookies ([#1915](https://github.com/modelcontextprotocol/inspector/issues/1915)), +auth/token URL overrides ([#1906](https://github.com/modelcontextprotocol/inspector/issues/1906)), file-backed secrets where no OS keychain exists ([#1950](https://github.com/modelcontextprotocol/inspector/issues/1950)), +paste-MCP-JSON ([#904](https://github.com/modelcontextprotocol/inspector/issues/904)), and registry discovery ([#1101](https://github.com/modelcontextprotocol/inspector/issues/1101)). + +### 4.10 Workspace and layout + +Multiple servers side by side — the actual shape of debugging a gateway, or comparing a +server against a reference implementation. Detachable/resizable panels, remembered layout per +server, density modes, and full-collapse ([#928](https://github.com/modelcontextprotocol/inspector/issues/928)). Prerequisite for cross-server timeline +correlation. + +### 4.11 Performance at scale + +A 1000-tool server or a long-running session should not degrade. Virtualize the long lists and +logs; cap in-memory protocol history with spill-to-disk; truncate large payloads by default +with explicit expansion (which is also the right default for reference results, §3.6). + +### 4.12 Accessibility and keyboard-first operation + +Full keyboard operation across every tab, correct roles and labels, high-contrast support, +and `prefers-reduced-motion` (which the timeline's animations will make newly relevant). We +have a Storybook a11y harness already; the gap is coverage, not tooling. + +### 4.13 Onboarding + +A first run currently presents an empty server list and no path forward. Add a guided first +connection, one-click example servers drawn from `test-servers/`, and inline links from each +panel to the relevant spec section. + +### 4.14 Plugin architecture + +[#1025](https://github.com/modelcontextprotocol/inspector/issues/1025). The multiplier on everything above — custom panels, custom transports (§3.1), +interceptor hosting (§3.7), and community-contributed views without core changes. Sequenced +late deliberately: designing a plugin API before the timeline, diff, and session format exist +would mean designing it against the wrong surfaces. + +--- + +## 5. Sequencing + +Four phases of roughly six weekly milestones each. Track A items appear where their upstream +signal is expected; Track B items are placed to unblock Track A wherever possible. + +### Phase 1 — Foundations (~`v2.2` – `v2.7`, Aug–Sep 2026) + +_Build the general surfaces the rest of the plan renders into, and clear the debt that makes +first impressions bad._ + +- 🅑 **Zoomable timeline v1** — lanes, spans, zoom/pan, click-through +- 🅑 **Argument editor workstream** (§4.7) — closes six issues as one +- 🅑 **Connection Doctor** (§4.8) + the local-host connection fixes +- 🅐 `Last-Event-ID` resumption ([#920](https://github.com/modelcontextprotocol/inspector/issues/920)); `Mcp-Name` on Tasks ([#1917](https://github.com/modelcontextprotocol/inspector/issues/1917)); discover checkmarks ([#1887](https://github.com/modelcontextprotocol/inspector/issues/1887)) +- 🅐 `server.json` support ([#922](https://github.com/modelcontextprotocol/inspector/issues/922)) — prerequisite for Server Cards +- ⚙️ Windows CI/gate fixes already in `v2.2.0` + +### Phase 2 — Artifacts and comparison (~`v2.8` – `v2.13`, Sep–Nov 2026) + +_Make sessions into things you can keep, share, and compare._ + +- 🅑 **Session record / replay / share** (§4.2) — format shared with audit transcript +- 🅑 **Diff primitive** (§4.3) — then wire capability diff ([#1034](https://github.com/modelcontextprotocol/inspector/issues/1034)) +- 🅑 **Command palette and global search** (§4.4) +- 🅐 **OTLP export and audit transcript** (§3.4) — no upstream dependency +- 🅐 **Grouped sidebars** (§3.10) on client-side heuristics +- 🅐 Strict schema validation ([#1005](https://github.com/modelcontextprotocol/inspector/issues/1005), [#1015](https://github.com/modelcontextprotocol/inspector/issues/1015)) +- 🅐 Timeline lanes for tasks and sessions (falls out of Phase 1) + +### Phase 3 — Automation and spec catch-up (~`v2.14` – `v2.20`, Nov 2026 – Jan 2027) + +_Turn the Inspector into something you can run in CI, and absorb the SEPs that have landed._ + +- 🅑 **Saved calls / collections** (§4.5) → **assertions and CI flows** (§4.6) +- 🅐 **Conformance runner** (§3.11) — shares the assertion engine +- 🅐 **File uploads** (§3.8) — assumes the TS SDK reference impl has shipped +- 🅐 **Server Card preview + card-vs-reality diff** (§3.2) — assumes SEP-2127 has settled +- 🅐 **Skills view** (§3.9) — assumes SEP-2640 accepted +- 🅑 Performance at scale (§4.11); accessibility pass (§4.12) + +### Phase 4 — Frontier (~`v2.21` – `v2.27`, Jan–Feb 2027) + +_The items whose shape we cannot yet commit to, plus the multiplier._ + +- 🅐 **Interceptor test bench** (§3.7) — and a decision on owning the WG's CLI deliverable +- 🅐 **Triggers/events receiver** (§3.5) — design throughout, build only if the SEP lands +- 🅐 **Transport/session work** (§3.1) — proxy harness, stateless verification, third era +- 🅐 **ID-JAG / Cross-App Access flow** (§3.4) +- 🅑 **Plugin architecture** (§4.14) — designed against surfaces that now exist +- 🅑 Workspace and layout (§4.10); onboarding (§4.13) + +### Standing commitments across all phases + +- **Weekly milestone cadence** and the `npm run ci` gate are unchanged. +- **Bug and triage capacity is reserved, not scheduled.** The board's Incoming queue keeps + flowing regardless of phase. +- **WG liaison**: attend Transports, Agents, Triggers, Interceptors, and Server Card sessions + and feed implementation experience back. Several items above are as much _inputs to_ the + spec as outputs of it. + +--- + +## 6. What we are deliberately not doing + +Stating these so they are decisions rather than oversights. + +- **Not building bespoke panels per SEP.** Where a new feature can render into the timeline, + the diff, or the session format, it does. A new top-level tab needs justification. +- **Not chasing pre-Draft SEPs.** 🔴 items get a tracking issue and a WG liaison, not code. + We were burned by this in v1. +- **Not publishing `core/` as a package this cycle.** [#1636](https://github.com/modelcontextprotocol/inspector/issues/1636) stays deferred; it adds an API + compatibility obligation we cannot yet afford. +- **Not adding transports beyond what the spec blesses**, per the roadmap — but §3.1 makes + _custom_ transports loadable so the community can experiment. +- **Not building a second extension mechanism.** If we host interceptors, they run on the + plugin architecture (§4.14). + +--- + +## 7. Open questions + +For WG discussion before this plan is adopted. + +1. **Does the private roadmap doc change §3?** This plan is built from the public roadmap; the + private doc may carry timelines or themes it omits. +2. **Do we claim the Interceptors WG's "CLI client for interceptor invocation and testing"?** + It is Ideating and unowned, it describes our CLI, and we have a co-lead in common. If yes, + it needs milestone allocation in Phase 3, not Phase 4. +3. **How far do we take the conformance role?** §3.11 and §4.6 point at "the Inspector tells + you whether your server is correct." That is a real expansion of mission — worth an + explicit yes or no, and possibly a charter amendment. +4. **Who owns the triggers/events reachability problem?** A publicly reachable callback + endpoint on a localhost dev tool is a security question as much as a UX one, and it needs + an owner before Phase 4. +5. **Is the ~50/50 capacity split right?** It is an assertion in this draft, not a measurement. +6. **Timeline v1 scope.** The §4.1 sketch is deliberately broad. Which parts are v1 and which + are follow-ups should be settled before Phase 1 starts. + +--- + +## 8. Sources + +- [MCP Roadmap](https://modelcontextprotocol.io/development/roadmap) (last updated 2026-03-05) +- WG charters: [Inspector V2](https://modelcontextprotocol.io/community/working-groups/inspector-v2) · [Server Card](https://modelcontextprotocol.io/community/working-groups/server-card) · [Triggers & Events](https://modelcontextprotocol.io/community/working-groups/triggers-events) · [Agents](https://modelcontextprotocol.io/community/working-groups/agents) · [Interceptors](https://modelcontextprotocol.io/community/working-groups/interceptors) · [File Uploads](https://modelcontextprotocol.io/community/working-groups/file-uploads) · [Skills Over MCP](https://modelcontextprotocol.io/community/working-groups/skills-over-mcp) +- IG charters: [Primitive Grouping](https://modelcontextprotocol.io/community/interest-groups/primitive-grouping) · [Tool Annotations](https://modelcontextprotocol.io/community/interest-groups/tool-annotations) · [Enterprise-Managed Authorization](https://modelcontextprotocol.io/community/interest-groups/enterprise-managed-authorization) +- Internal: [`specification/v2_new_spec_impact.md`](../specification/v2_new_spec_impact.md) · [`specification/v2_scope.md`](../specification/v2_scope.md) · [`specification/v2_ux_features.md`](../specification/v2_ux_features.md) +- [Inspector V2 project board (#28)](https://github.com/orgs/modelcontextprotocol/projects/28) From f060c97e8fd9b5a00673ddd2580ccebb539bbd11 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 15:47:01 -0400 Subject: [PATCH 093/941] docs: mirror the smoke-isolation invariant into copilot-instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md requires review-relevant changes to land in the Copilot mirror in the same PR, and the catalog-isolation rules are review-enforceable: a new web smoke that spawns its own server, or a teardown that removes a work dir without first awaiting stopChild, is something a reviewer should flag. Mirrored as one line under "Gates and PR hygiene" rather than the AGENTS.md paragraph. The mirror is read on every review and its own rule is "a distillation, not a copy" — the mechanism (why the 409 appeared, why only the catalog and not HOME is redirected, how the contract is tested) belongs in AGENTS.md, while what a reviewer needs is the invariant and the two shapes that violate it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 250a8300..0628741c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,6 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. - **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. +- **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. From f4529df219b8c4327109c97fa04d446ba0b93e81 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:20:14 -0400 Subject: [PATCH 094/941] docs: document the DCO signoff requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DCO check fails any PR with an unsigned commit, and the rule appeared nowhere in the repo — not AGENTS.md, CONTRIBUTING.md, README.md, or the Copilot mirror. A hard merge gate that is invisible until it fails, whose remedy is a history rewrite, is exactly what AGENTS.md exists to capture. It blocked #1978. Added to the "When work is complete" list beside the other PR-hygiene gates, weighted toward prevention over recitation: `git config format.signOff true` once per clone beats remembering `-s`, because the failure is only discovered after pushing. The repair path (`git rebase --signoff` + `--force-with-lease`) carries the rebase caveat and the empty-remediation-commit alternative for when you are not the sole author, plus a note that the signoff is a DCO assertion in the author's own name and is never applied on someone else's behalf. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for PR-hygiene rules — two lines next to `Closes #N` and the version label, since the mirror is read on every review and is a distillation, not a copy. Not added to CONTRIBUTING.md: external contributors file issues rather than PRs, so DCO never reaches them, and documenting it there would muddy that. Closes #1979 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + AGENTS.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..f9d995d3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,6 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..6a19dfb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,6 +207,10 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. + - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). If others are on the branch, add an empty remediation commit instead. + - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) From b3242bc04c0748177bb6ea243f65be451d42fecc Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:29:54 -0400 Subject: [PATCH 095/941] fix(docs): format.signOff does not sign commits, and remediation is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both review points were factually right and my original text was wrong. 1. `git config format.signOff true` — which I had recommended as the *better* option — only defaults the -s flag for `git format-patch`. `git commit` never reads it, and there is no `commit.signoff` equivalent. Verified all three in a throwaway repo: format.signOff → no trailer; commit.signoff → no effect; a prepare-commit-msg hook → works. Shipping that advice would have been worse than saying nothing, since it looks like a fix and silently changes nothing. Now recommends `git commit -s`, gives the verified hook for automation, and names format.signOff explicitly as a trap — the name invites exactly the mistake I made, so it is worth warning about rather than omitting. 2. The DCO app's empty remediation-commit flow needs `allowRemediationCommits.individual`. This repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. The escape hatch I offered does not exist here; rewriting is the only repair, so the text now says to coordinate before rewriting shared history instead. Mirror updated with both corrections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f9d995d3..ffc4c38f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; `git commit -s`, or `git config format.signOff true` once per clone. Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index 6a19dfb8..afbb05c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,8 +208,20 @@ All work should be driven by items on the project board. - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. - - **Prevent it, don't remember it.** Commit with `git commit -s`, or — better — run `git config format.signOff true` once per clone so the trailer is added automatically and the rule stops depending on memory. This matters more than the rule itself, because the failure is invisible until after you have pushed. - - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). If others are on the branch, add an empty remediation commit instead. + - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: + + ```sh + cat > .git/hooks/prepare-commit-msg <<'HOOK' + #!/bin/sh + grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ + --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + HOOK + chmod +x .git/hooks/prepare-commit-msg + ``` + + ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. + + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. From 6899e0ae1e821e0a6d7270dfbb02b29fbfb463f9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:46:32 -0400 Subject: [PATCH 096/941] fix(docs): make the signoff hook identity-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook skipped insertion on any `Signed-off-by:` line, which is wrong for a message reused from another commit — cherry-pick, `git commit -c` — since that message already carries someone else's trailer. The DCO check requires one matching the *author*, so the guard would be satisfied while the commit still failed the gate: the exact silent-no-op failure mode this bullet warns about. Match this identity's exact trailer instead. Verified: a plain commit signs once; a message already carrying another author's signoff now gets the real author's added alongside it; `git commit -s` is not double-signed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afbb05c4..ae7cb54d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,8 +213,12 @@ All work should be driven by items on the project board. ```sh cat > .git/hooks/prepare-commit-msg <<'HOOK' #!/bin/sh - grep -q '^Signed-off-by:' "$1" || git interpret-trailers --in-place \ - --trailer "Signed-off-by: $(git config user.name) <$(git config user.email)>" "$1" + # Match THIS identity's exact trailer, not any Signed-off-by: a message + # reused from another commit (cherry-pick, `git commit -c`) already carries + # someone else's, and the DCO check requires one matching the author — so a + # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. + SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" + grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" HOOK chmod +x .git/hooks/prepare-commit-msg ``` From 3c93c0502104159a8b50dfd16bf6ad696f776d75 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 22:59:16 -0400 Subject: [PATCH 097/941] fix(docs): the DCO app does have a write-access override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims were wrong, both verified against the app's own documentation (dcoapp/app): "The DCO also creates an override button for users with write access to the repository to create a successful check." So "there is no per-PR override" and "there is no non-rewrite escape hatch" were both false, and a maintainer following them would conclude that rewriting history was the only way to unblock a PR. Documented, but deliberately not as a repair. The override only silences the check; it does not make the author certify anything, and the certification is the whole point of a DCO signoff. Using it on your own unsigned commits asserts nothing while looking like compliance — so the text says to treat it as unavailable and sign the commits instead. The same source confirms two things the doc already claimed: remediation commits are disabled by default, and the trailer must match the commit author's email (which is what the identity-aware hook in 6899e0ae exists for). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ae7cb54d..fc495082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,7 +207,7 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no per-PR override and no partial credit: one unsigned commit out of six fails the whole check. + - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: ```sh @@ -225,7 +225,7 @@ All work should be driven by items on the project board. ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. - - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **There is no non-rewrite escape hatch:** the DCO app's empty "remediation commit" flow requires `allowRemediationCommits.individual`, and this repo ships no DCO config, so it runs with that disabled and the original unsigned commits keep failing. If others are working on the branch, coordinate with them before rewriting rather than reaching for a remediation commit. + - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. From eb5bba62ede2c27d9082a833bcf23e34d4cda2ee Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:00:01 -0400 Subject: [PATCH 098/941] fix(tui): bundle React-rendering deps so a consumer install can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ink-form` and `ink-scroll-view` were externalized by the TUI bundler and both declare a loose `react` peer (">=18"). Installed under a project that has its own React 18, npm hoists them to that project's root — where the peer is satisfied — while the Inspector's React 19 nests underneath. The bundle then renders through React 19 while those two call hooks on React 18, whose dispatcher is null, so opening a tool test form (or any scroll view) dies with "Cannot read properties of null (reading 'useState')". Inline them instead: their `import "react"` is emitted into the bundle and so resolves from the build directory exactly like the bundle's own, with npm's placement no longer part of the decision. This also pins their transitive deps to what this repo resolved — notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency, so consumers were getting the React-18-era v5. `ink` stays external: it can't be bundled (its CJS `signal-exit@3` fails ESM interop with `Dynamic require of "assert"`) and doesn't need to be — its ">=19" peer keeps npm from hoisting it next to an unusable React. Both packages are dropped from the root `dependencies`, since the tarball now ships their code rather than having consumers install them. `clients/tui/__tests__/tsupConfig.test.ts` is the durable guard: the failure is invisible in this repo and in every smoke (a dev install has one React) and only appears once the package is installed under another project, so the invariant is asserted against the config instead — every dependency declaring a `react` peer must be bundled unless exempted. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 1 + AGENTS.md | 2 + README.md | 1 + clients/tui/README.md | 32 ++++++ clients/tui/__tests__/tsupConfig.test.ts | 130 +++++++++++++++++++++++ clients/tui/tsup.config.ts | 22 +++- package-lock.json | 111 ------------------- package.json | 2 - 8 files changed, 185 insertions(+), 116 deletions(-) create mode 100644 clients/tui/__tests__/tsupConfig.test.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..24367041 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,6 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). - **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..8cbfdcf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,8 @@ The same **placement** rule covers anything reached only through **root-owned co - A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. - `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. +**A package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). + The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. ## Contributing diff --git a/README.md b/README.md index 21b91d04..7f952494 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. - **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root while the Inspector's React 19 nests underneath — two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). `ink` is the one React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball diff --git a/clients/tui/README.md b/clients/tui/README.md index 7a784a97..bcf102c3 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -106,3 +106,35 @@ Tests live in `__tests__/`. The coverage gate currently covers the TUI's non-React logic (server resolution, logger, tab metadata, and the `utils/` form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in `vitest.config.ts` pending a renderer-based follow-up. + +### Bundling: React-rendering dependencies must be inlined (#1952) + +`tsup.config.ts` splits the TUI's dependencies into bundled (`noExternal`) and +externalized (`external`). For anything that **renders React components**, that +choice decides which React instance it gets at runtime, and getting it wrong +crashes the TUI in a way nothing in this repo can see. + +An external package's `import "react"` resolves from wherever npm placed **that +package** — and npm places it next to a React satisfying its own peer range. +`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that +installs the Inspector alongside its own React 18 satisfies them: they hoist to +that project's root while the Inspector's React 19 nests underneath. The bundle +then renders through React 19 while those two call hooks on React 18, whose +dispatcher is null — so opening a tool test form (or any scroll view) dies with +`TypeError: Cannot read properties of null (reading 'useState')`. + +Bundling them removes npm from the decision: their `import "react"` is emitted +into `build/index.js`, so it resolves from the build directory exactly like the +bundle's own. It also pins their transitive deps to what *this* install +resolved — notably `ink-select-input@6` via the `overrides` entry, since npm +ignores a dependency's `overrides` and a consumer install would otherwise pull +the React-18-era v5 that `ink-form` asks for. + +`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` +dependency fails ESM interop with `Dynamic require of "assert" is not +supported`), and it doesn't need to be — its `react` peer is `">=19"`, which +keeps npm from hoisting it next to a React the Inspector couldn't also use. + +`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a +`react` peer must be in `noExternal` unless it is listed as external by design. +Add a React-rendering dependency, and that test tells you to bundle it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts new file mode 100644 index 00000000..b66cf13b --- /dev/null +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -0,0 +1,130 @@ +/** + * Bundling invariant for the TUI's React-rendering dependencies (#1952). + * + * The published TUI is a single ESM bundle whose bare `import "react"` resolves + * from `clients/tui/build/`. Any package left *external* resolves its own + * `react` from wherever npm placed **it** instead — and npm places a package + * next to a version satisfying its declared peer range. `ink-form` and + * `ink-scroll-view` accept `react: ">=18"`, so a consumer project holding React + * 18 satisfies them, they hoist to that project's root, and the Inspector's + * React 19 nests beneath it. Two React copies later, the first hook either of + * them calls reads a null dispatcher and the TUI dies with + * "Cannot read properties of null (reading 'useState')" — the moment a tool + * test form or a scroll view mounts. + * + * Inlining them removes npm from the decision entirely: their `import "react"` + * is emitted into the bundle, so it resolves exactly where the bundle's does. + * + * This test is the durable guard, because the failure is invisible in the repo + * (a dev install has one React) and in every smoke (same) — it only appears + * once the package is installed *under* another project that renders React. + */ + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Options } from "tsup"; +import tsupConfig from "../tsup.config.js"; + +const clientDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** + * `defineConfig()` is typed as a union — a single options object, an array of + * them, or a function returning either. The TUI config is the first form, so + * narrow to it at runtime rather than assuming: a future config that grows a + * second build should fail loudly here instead of silently checking nothing. + */ +function singleConfig(config: typeof tsupConfig): Options { + if (typeof config !== "object" || Array.isArray(config)) { + throw new Error( + "clients/tui/tsup.config.ts is expected to export one options object", + ); + } + return config; +} + +/** The string entries of an `external` / `noExternal` list (ours are all strings but the type allows RegExp). */ +function stringEntries(list: (string | RegExp)[] | undefined): string[] { + return (list ?? []).filter((entry) => typeof entry === "string"); +} + +/** + * The one React-rendering dependency deliberately left external. + * + * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM + * interop ("Dynamic require of \"assert\" is not supported") — and does not + * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a + * React the Inspector itself could not use. + */ +const EXTERNAL_BY_DESIGN = new Set(["ink"]); + +function readPackageJson(name: string): { + peerDependencies?: Record<string, string>; +} { + // Resolve through the client's own node_modules rather than `require.resolve` + // on the package name: several of these packages export only their entry + // point, so `<name>/package.json` is not a resolvable subpath. + const manifest = path.join(clientDir, "node_modules", name, "package.json"); + return JSON.parse(readFileSync(manifest, "utf8")); +} + +/** Dependencies that declare a `react` peer — i.e. that render React themselves. */ +function reactRenderingDependencies(): string[] { + const { dependencies } = JSON.parse( + readFileSync(path.join(clientDir, "package.json"), "utf8"), + ) as { dependencies: Record<string, string> }; + + return Object.keys(dependencies).filter( + (name) => readPackageJson(name).peerDependencies?.react !== undefined, + ); +} + +const config = singleConfig(tsupConfig); +const noExternal = stringEntries(config.noExternal); +const external = stringEntries(config.external); + +describe("tui tsup config", () => { + it("finds the React-rendering dependencies to check", () => { + // A guard on the guard: if this resolves to nothing (a rename, a moved + // node_modules), every assertion below would pass vacuously. + expect(reactRenderingDependencies().length).toBeGreaterThan(0); + }); + + it("bundles every React-rendering dependency that can be bundled", () => { + const shouldInline = reactRenderingDependencies().filter( + (name) => !EXTERNAL_BY_DESIGN.has(name), + ); + + expect(shouldInline.length).toBeGreaterThan(0); + for (const name of shouldInline) { + expect( + noExternal, + `${name} renders React, so it must be bundled`, + ).toContain(name); + expect(external, `${name} must not be external`).not.toContain(name); + } + }); + + it("keeps react itself external, as the single shared instance", () => { + expect(external).toContain("react"); + expect(noExternal).not.toContain("react"); + }); + + it("documents each React-rendering package left external", () => { + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + } + }); + + it("verifies the deps it exempts are still declared", () => { + // If `ink` ever leaves the dependency list, the exemption above is stale + // and would silently excuse a future package that took its name. + for (const name of EXTERNAL_BY_DESIGN) { + expect(reactRenderingDependencies()).toContain(name); + } + }); +}); diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 92a637b3..1a90e87f 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -15,12 +15,28 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - noExternal: [/^@inspector\/core/], + // Every package here renders React components, so it MUST share the one + // React instance the bundle imports. Bundling them is what guarantees that: + // an inlined package's `import "react"` is emitted into build/index.js and so + // resolves from *this* directory, exactly like the bundle's own — no consumer + // install layout can point it somewhere else (#1952). Left external, npm is + // free to hoist them next to a *different* React: `ink-form` and + // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer + // project holding React 18 satisfies it and gets them hoisted to its own root + // while the Inspector's React 19 nests under it — two React copies, and the + // first hook they call reads a null dispatcher ("Cannot read properties of + // null (reading 'useState')") the moment a tool test form or a scroll view + // mounts. `__tests__/tsupConfig.test.ts` guards this list. + noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ + // `react` is deliberately external — it is the single instance everything + // above resolves to, from this build directory. "react", + // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` + // dependency fails ESM interop with "Dynamic require of \"assert\""), and + // it does not need to be — its `react` peer is ">=19", which keeps npm + // from hoisting it next to a React the Inspector could not also use. "ink", - "ink-form", - "ink-scroll-view", "open", "commander", "pino", diff --git a/package-lock.json b/package-lock.json index 61e95289..972b65f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,6 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", @@ -2181,21 +2179,6 @@ } } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2552,76 +2535,6 @@ } } }, - "node_modules/ink-form": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ink-form/-/ink-form-2.0.1.tgz", - "integrity": "sha512-vo0VMwHf+HOOJo7026K4vJEN8xm4sP9iWlQLx4bngNEEY5K8t30CUvVjQCCNAV6Mt2ODt2Aq+2crCuBONReJUg==", - "license": "MIT", - "dependencies": { - "ink-select-input": "^5.0.0", - "ink-text-input": "^6.0.0" - }, - "peerDependencies": { - "ink": ">=4", - "react": ">=18" - } - }, - "node_modules/ink-scroll-view": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/ink-scroll-view/-/ink-scroll-view-0.3.7.tgz", - "integrity": "sha512-lUBLxSbVry/+UtJhuvRu6wumP43+ScVp69J7e+hmYwz1kTkahWfkVwWOu7Mn1DMPb8AU8bnsmEsr6kvSJvnaRw==", - "license": "MIT", - "peerDependencies": { - "ink": "^5 || ^6 || ^7", - "react": "^18 || ^19" - } - }, - "node_modules/ink-select-input": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", - "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", - "license": "MIT", - "dependencies": { - "figures": "^6.1.0", - "to-rotated": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5.0.0", - "react": ">=18.0.0" - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/ink-text-input/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2732,18 +2645,6 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4080,18 +3981,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-rotated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", - "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index 05155e99..c09afb9d 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,6 @@ "commander": "^13.1.0", "hono": "^4.12.18", "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", From bd6c1072bf331f402d192b30824e19e9bf193119 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:09:01 -0400 Subject: [PATCH 099/941] =?UTF-8?q?fix(docs):=20drop=20the=20signoff=20hoo?= =?UTF-8?q?k=20=E2=80=94=20it=20cannot=20know=20the=20commit=20author?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 4 was right that the hook signs with the configured identity while the DCO check requires a trailer matching the commit *author*, and that the two diverge on cherry-pick and `git commit -c`. I tried the suggested author-aware guard and it does not work: inside prepare-commit-msg, `git var GIT_AUTHOR_IDENT` returns the config identity, not the preserved author. Verified end to end — cherry-picking Bob's commit as Alice yields author=Bob, committer=Alice, `Signed-off-by: Alice`. That commit both still fails the gate and certifies work Alice did not write, which is the one thing the last bullet of this section forbids. Three review rounds have now found three holes in this hook, each fix opening the next. That is a wrong mechanism rather than an unfinished one, so the hook is out. A subtly-broken hook in the file that governs how this repo is worked on is worse than no hook. The bullet now recommends `git commit -s` and names BOTH false automations — `format.signOff` and the hook — recording the cherry-pick finding so the next person doesn't rediscover it, and pointing anyone who wants automation at a layer that actually knows the author. Mirror updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 18 ++++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ffc4c38f..5e7edd68 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (note `format.signOff` does *not* sign `git commit` — only `format-patch`; automate with a `prepare-commit-msg` hook instead). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index fc495082..ac9fb499 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,22 +208,12 @@ All work should be driven by items on the project board. - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - - **Prevent it, don't remember it** — the failure is invisible until after you have pushed. Commit with **`git commit -s`**. To stop relying on memory, add a `prepare-commit-msg` hook, which is the only mechanism that actually signs an ordinary `git commit`: + - **Prevent it with `git commit -s`** — the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not: - ```sh - cat > .git/hooks/prepare-commit-msg <<'HOOK' - #!/bin/sh - # Match THIS identity's exact trailer, not any Signed-off-by: a message - # reused from another commit (cherry-pick, `git commit -c`) already carries - # someone else's, and the DCO check requires one matching the author — so a - # bare `grep '^Signed-off-by:'` would skip and leave the commit failing. - SOB="Signed-off-by: $(git config user.name) <$(git config user.email)>" - grep -qF "$SOB" "$1" || git interpret-trailers --in-place --trailer "$SOB" "$1" - HOOK - chmod +x .git/hooks/prepare-commit-msg - ``` + - ⚠️ **`git config format.signOff true` does nothing here.** Despite the name it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing. + - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. - ⚠️ **`git config format.signOff true` does NOT do this** — despite the name, it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing, so it is a trap worth naming rather than omitting. + If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. From 3c1bbbcb5fda7e4eb393f7323f1b347726ccc440 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:15:35 -0400 Subject: [PATCH 100/941] fix(tui): correct ink-form's misspelled incomplete-form hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint under a form with unfilled required fields reads "you have not competed yet". The string is upstream's, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override it, and `ink-form` was last published in 2024 — but it renders in the Inspector's tool, prompt, and resource test forms, so it is ours to fix in practice. Bundling `ink-form` (previous commit) is what makes it patchable: an esbuild `onLoad` hook rewrites the label as the file enters the bundle. The hook throws when the string is absent rather than passing the source through, so an `ink-form` upgrade cannot silently retire the patch or leave it aimed at a string that no longer exists — the tests drive it against the real dependency source for the same reason. Reported upstream as lukasbach/ink-form#14. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 16 +++++++ clients/tui/__tests__/tsupConfig.test.ts | 39 ++++++++++++++++- clients/tui/tsup.config.ts | 54 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 1 deletion(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index bcf102c3..58e8be40 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -138,3 +138,19 @@ keeps npm from hoisting it next to a React the Inspector couldn't also use. `__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a `react` peer must be in `noExternal` unless it is listed as external by design. Add a React-rendering dependency, and that test tells you to bundle it. + +### The `ink-form` label patch + +Bundling `ink-form` also makes it patchable, which one label needs: the hint +under an incomplete form reads "you have not **competed** yet". It is upstream's +string, hardcoded in `ink-form/lib/SubmitButton.js` with no prop to override, +and `ink-form` was last published in 2024 — so `tsup.config.ts` corrects it with +an esbuild `onLoad` hook as the file enters the bundle. It is reported upstream +as [lukasbach/ink-form#14](https://github.com/lukasbach/ink-form/issues/14); if +a release ever carries the fix, drop the patch. + +The hook **throws** when the string isn't found rather than passing the source +through. A silent no-op would let an `ink-form` upgrade retire the patch without +anyone noticing it had stopped applying — or leave a patch aimed at a string +that no longer exists. If the build fails there, check whether upstream fixed +the typo and delete the patch instead of re-targeting it. diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index b66cf13b..1ad3490c 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -25,7 +25,10 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; -import tsupConfig from "../tsup.config.js"; +import tsupConfig, { + INK_FORM_INCOMPLETE_HINT, + fixInkFormIncompleteHint, +} from "../tsup.config.js"; const clientDir = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -120,6 +123,40 @@ describe("tui tsup config", () => { } }); + it("corrects ink-form's misspelled incomplete-form hint", () => { + // Read the real dependency, so an `ink-form` upgrade that fixes or rewords + // the label fails here — the patch must then be removed, not left silently + // matching nothing. + const submitButton = readFileSync( + path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", + ), + "utf8", + ); + + const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); + expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("fails loudly rather than silently skipping a label it cannot find", () => { + expect(() => + fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), + ).toThrow(/no longer contains the ink-form label/); + }); + + it("only rewrites the misspelling", () => { + // The two strings must differ by exactly the fix, or the patch is silently + // changing copy nobody reviewed. + expect(INK_FORM_INCOMPLETE_HINT.fixed).toBe( + INK_FORM_INCOMPLETE_HINT.typo.replace("competed", "completed"), + ); + }); + it("verifies the deps it exempts are still declared", () => { // If `ink` ever leaves the dependency list, the exemption above is stale // and would silently excuse a future package that took its name. diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 1a90e87f..3a4c66f7 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,10 +1,63 @@ import { defineConfig } from "tsup"; +import type { Plugin } from "esbuild"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(dirname, "../.."); +/** + * `ink-form` hardcodes a misspelled hint under an incomplete form — "you have + * not competed yet" — with no prop to override it. It is upstream's string + * (`ink-form/lib/SubmitButton.js`), last published in 2024, but it renders in + * the Inspector's tool/prompt/resource test forms, so we correct it on the way + * into the bundle. Reported upstream as lukasbach/ink-form#14; drop this patch + * if a release ever carries the fix. + * + * This is only possible because `ink-form` is inlined (see `noExternal` below). + */ +export const INK_FORM_INCOMPLETE_HINT = { + typo: "There are still required inputs you have not competed yet.", + fixed: "There are still required inputs you have not completed yet.", +}; + +/** + * Applies the correction above, throwing if the string is no longer there. + * + * Failing loudly is the point: a silent no-op would let an `ink-form` upgrade + * (or a fixed upstream, or a reworded label) quietly retire this patch with + * nobody noticing it had stopped applying — or, worse, leave a patch here for a + * string that no longer exists. If this throws, check whether upstream fixed + * the typo; if so, delete this patch rather than re-targeting it. + */ +export function fixInkFormIncompleteHint(source: string, file: string): string { + if (!source.includes(INK_FORM_INCOMPLETE_HINT.typo)) { + throw new Error( + `${file} no longer contains the ink-form label this build patches ` + + `(${JSON.stringify(INK_FORM_INCOMPLETE_HINT.typo)}). If upstream fixed ` + + `the typo, remove fixInkFormIncompleteHint from tsup.config.ts.`, + ); + } + return source.replaceAll( + INK_FORM_INCOMPLETE_HINT.typo, + INK_FORM_INCOMPLETE_HINT.fixed, + ); +} + +const inkFormLabelPatch: Plugin = { + name: "ink-form-label-patch", + setup(build) { + build.onLoad( + { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + async ({ path: file }) => ({ + contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), + loader: "js", + }), + ); + }, +}; + export default defineConfig({ entry: ["index.ts"], format: ["esm"], @@ -44,6 +97,7 @@ export default defineConfig({ "@modelcontextprotocol/core", "@napi-rs/keyring", ], + esbuildPlugins: [inkFormLabelPatch], esbuildOptions(options) { options.alias = { "@inspector/core": path.join(repoRoot, "core"), From c158ad809fb9869daabffa39df0227f4c416874e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:22:22 -0400 Subject: [PATCH 101/941] fix(docs): correct the DCO rules against the validator source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 was right on all three counts. Read dcoapp/app/lib/dco.js this time rather than the README, which is where the earlier errors came from: const authors = [commit.author.name, commit.committer.name] const emails = [commit.author.email, commit.committer.email] if (isMerge || ...) continue // parents.length > 1 else if (author && author.type === 'Bot') continue So, corrected: - Scope. Merge commits and bot-authored commits are skipped, not gated. "Every commit" overstated it and could prompt a needless history rewrite. - Matching. The trailer may match the author OR the committer, not the author alone. A cherry-picker's own signoff is accepted. - Certification. The DCO expressly covers submitting work created by others that you have the right to submit, so signing off a cherry-pick is legitimate. My "never sign off someone else's commit" would have told a maintainer not to do a correct thing. The real prohibition is narrower: never fabricate a trailer in another person's name. This also retracts the mechanical argument in bd6c1072 for dropping the hook — it does satisfy the check, since you are the committer. The recommendation against it stands on honest grounds now: the trailer is a certification, a hook makes it for every commit automatically including work merely applied on someone else's behalf, and it provably cannot tell the difference. A judgment about deliberateness, not a claim that it breaks. Mirror corrected to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5e7edd68..2d229764 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. -- **Every commit carries a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit. Use `git commit -s` (`format.signOff` does *not* sign `git commit` — only `format-patch` — and a `prepare-commit-msg` hook signs with the committer's identity, not the author's, so it breaks on cherry-picks). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. +- **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. - Update the relevant `README.md` / `AGENTS.md` when a change adds, removes, renames, or repurposes a file or folder, changes the structure or tech stack, or introduces a command, dependency, or architectural pattern. ## What to prioritize in review diff --git a/AGENTS.md b/AGENTS.md index ac9fb499..506ea43f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,16 +207,14 @@ All work should be driven by items on the project board. - **Branch names start with the target version segment.** The first path segment must be the version whose base branch the PR targets — `v2/` for work on `v2/main`, `v1/` for work on `v1/main` — followed by the usual type and slug: `v2/ci/restore-claude-workflow`, `v2/fix/oauth-scope-union`, `v1/fix/proxy-ssrf-pin`. Not `ci/restore-claude-workflow`. This keeps the two lines legible in `git branch -a` and in the PR list once v1 and v2 branches coexist on the same remote, and it matches the base branches themselves (`v2/main`, `v1/main`). - When work is complete: - Run `npm run ci` from the root — the mandatory pre-push gate (see [Mandatory pre-push gate](#mandatory-pre-push-gate)). `npm run validate` is the fast inner-loop check and is **not** a substitute: it runs no coverage gate, no smokes, and no Storybook tests. - - **Sign off every commit — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless **every** commit in it carries a `Signed-off-by: Author Name <author@example.com>` trailer matching its author. There is no partial credit: one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. + - **Sign off your commits — the DCO check is a hard merge gate.** The repo runs the [probot DCO app](https://probot.github.io/apps/dco/), which fails the PR unless each commit carries a `Signed-off-by: Name <email>` trailer whose name **and** email match either the commit's **author or its committer** (the validator checks both, so a cherry-picker's own signoff is accepted). Its only exemptions are **merge commits** and **bot-authored** commits, which it skips; everything else counts, and there is no partial credit — one unsigned commit out of six fails the whole check. The app *does* expose an override button to anyone with write access, but treat it as unavailable — see the repair bullet below for why. - **Prevent it with `git commit -s`** — the failure is invisible until after you have pushed, so make the flag habitual. Two things that look like automation and are not: - ⚠️ **`git config format.signOff true` does nothing here.** Despite the name it only defaults the `-s` flag for `git format-patch`; `git commit` never reads it, and there is no `commit.signoff` equivalent. Setting it looks like a fix and silently changes nothing. - - ⚠️ **Don't reach for a `prepare-commit-msg` hook either**, however tempting. A hook can only sign with your *configured* identity, while the DCO check requires a trailer matching the commit **author** — and the two diverge exactly when you least want a wrong answer. Verified: on `git cherry-pick`, the resulting commit keeps the original author but the hook signs as you, producing a commit that both still fails the gate and certifies someone else's work. `git var GIT_AUTHOR_IDENT` does **not** resolve the true author inside that hook, so an author-aware guard doesn't rescue it. - - If you want automation anyway, put it somewhere that knows the author — a CI pre-merge check, or a shell alias you invoke deliberately — not a blanket commit hook. + - ⚠️ **A `prepare-commit-msg` hook works, but think before installing one.** It would satisfy the check — a hook signs with your configured identity, and you are the committer, which the validator accepts. The objection is not mechanical but what it means: the trailer is a certification, and a hook makes it on your behalf for *every* commit, including work you merely cherry-picked or applied on someone else's behalf. Verified that the hook cannot distinguish those cases: inside `prepare-commit-msg`, `git var GIT_AUTHOR_IDENT` returns your config identity rather than the preserved author, so it cannot even tell that it is signing for someone else's authorship. Prefer `-s`, where each certification is a deliberate act. - **Repairing already-pushed commits** means rewriting them: `git rebase HEAD~<n> --signoff`, then `git push --force-with-lease`. Use `--force-with-lease` rather than `--force` so a concurrent push can't be silently clobbered, and only rewrite when you are the sole author and nobody else has based work on the branch — the usual [perils of rebasing](https://git-scm.com/book/en/v2/Git-Branching-Rebasing). **Rewriting is the only legitimate repair, and the two apparent alternatives are not.** The app's empty "remediation commit" flow requires `allowRemediationCommits.individual`; this repo ships no `.github/dco.yml`, so it runs with that disabled and the original unsigned commits keep failing. The app *does* also give anyone with write access an override button that forces the check green — but it only silences the check, it does not make the author certify anything, and the signoff is the certification (see below). Using it on your own unsigned commits asserts nothing while looking like compliance, so don't: sign the commits instead. If others are working on the branch, coordinate with them before rewriting. - - The signoff is a **Developer Certificate of Origin** assertion in the author's own name, so it must be that author's identity — never sign off someone else's commit. + - The signoff is a **[Developer Certificate of Origin](https://developercertificate.org/)** assertion, made in **your own name**. It does not claim you wrote the code — the DCO expressly covers submitting work created by others that you have the right to submit — so signing off a commit you cherry-picked is legitimate. What is never acceptable is fabricating *someone else's* certification: don't add a trailer bearing another person's name or email. If a commit needs their signoff, they add it. - Open a PR against the matching base branch (`v1/main` for v1, `v2/main` for v2) and set the item's Status to **In Review** - **Attach screenshots as proof of functionality.** Any change to the web UI or the TUI must show its result: capture before/after screenshots (or a short GIF for an interaction) and put them in a **`pr-screenshots/` folder off the repo root**, creating it if it doesn't exist. That folder is **gitignored** — the images are working artifacts staged for upload, never committed to the source tree — so attach them to the PR body from there rather than referencing an in-repo path. Name them for what they show (`tools-tab-before.png`, `tools-tab-after.png`), not `Screenshot 2026-07-31 at 14.02.11.png`. - **Link the PR to its issue — mandatory for every PR, from anyone.** No PR is opened without an issue to reference; if one doesn't exist yet, create it first (labeled and on the board) rather than opening the PR and backfilling. Note also that only the **repo maintainers** open PRs at all (see [Contributing](#contributing)) — everyone else files a detailed issue. The PR body's **first line must be `Closes #<ISSUE_NUMBER>`**. ⚠️ Note: closing keywords only auto-link/auto-close for PRs targeting the repo's **default branch** (`main`). Because v2 PRs target `v2/main` (a non-default branch), `Closes #N` there is only a cross-reference — it will **not** create a hard link or close the issue on merge. (There is no `gh` flag for manual linking — `gh pr edit` has no `--add-issue`; closing keywords are the only mechanism GitHub exposes, and they're gated to the default branch.) From 39575ff060bcc7f4a7478a19d1d58ec568c39e2c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:29:13 -0400 Subject: [PATCH 102/941] test(tui): drive the ink-form patch through the plugin, and drop the esbuild type import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983. The patch's tests called the string helper directly, so nothing exercised the esbuild `onLoad` filter that decides whether the helper runs at all. A filter that stopped matching would leave the build green, the tests green, and the typo shipped — the silent no-op the throwing helper exists to prevent, reintroduced one level up. The plugin is now driven the way esbuild drives it (register, then invoke) against the real `ink-form` module path, and a second test pins the filter's scope: it matches that path and a Windows-separator one, and rejects `Form.js`, `ink-select-input`'s own `SubmitButton.js`, and a first-party `src/SubmitButton.jsx`. The plugin type came from `import type { Plugin } from "esbuild"`, but `esbuild` is tsup's transitive dependency, not one this client declares — it typechecked only while npm happened to hoist it, and would break on a lock change that nested it. Derive it from tsup's own `Options["esbuildPlugins"]` instead of adding a dependency for a type. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/__tests__/tsupConfig.test.ts | 75 ++++++++++++++++++++---- clients/tui/tsup.config.ts | 21 +++++-- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 1ad3490c..0cff33ca 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -27,7 +27,9 @@ import { fileURLToPath } from "node:url"; import type { Options } from "tsup"; import tsupConfig, { INK_FORM_INCOMPLETE_HINT, + INK_FORM_SUBMIT_BUTTON, fixInkFormIncompleteHint, + inkFormLabelPatch, } from "../tsup.config.js"; const clientDir = path.resolve( @@ -86,6 +88,15 @@ function reactRenderingDependencies(): string[] { ); } +/** The real `ink-form` module the label patch targets. */ +const submitButtonPath = path.join( + clientDir, + "node_modules", + "ink-form", + "lib", + "SubmitButton.js", +); + const config = singleConfig(tsupConfig); const noExternal = stringEntries(config.noExternal); const external = stringEntries(config.external); @@ -127,22 +138,64 @@ describe("tui tsup config", () => { // Read the real dependency, so an `ink-form` upgrade that fixes or rewords // the label fails here — the patch must then be removed, not left silently // matching nothing. - const submitButton = readFileSync( - path.join( - clientDir, - "node_modules", - "ink-form", - "lib", - "SubmitButton.js", - ), - "utf8", + const patched = fixInkFormIncompleteHint( + readFileSync(submitButtonPath, "utf8"), + submitButtonPath, ); - - const patched = fixInkFormIncompleteHint(submitButton, "SubmitButton.js"); expect(patched).toContain(INK_FORM_INCOMPLETE_HINT.fixed); expect(patched).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); }); + it("routes the real module through the plugin, not just the helper", async () => { + // The helper is only reached if the plugin's onLoad filter matches. Drive + // the plugin as esbuild would — register, then invoke — so a filter that + // stops matching the real module's path fails here instead of no-opping + // through a green build (the exact silence this patch exists to prevent). + type OnLoadCallback = (args: { + path: string; + }) => Promise<{ contents: string }>; + const registered: { filter: RegExp; callback: OnLoadCallback }[] = []; + + const build = { + onLoad: (options: { filter: RegExp }, callback: OnLoadCallback) => + registered.push({ filter: options.filter, callback }), + }; + // esbuild's `PluginBuild` carries far more than this patch touches, and the + // stub above deliberately implements only the one hook it registers — so + // the double cast is bridging a real structural gap, not hiding a mismatch. + // A hook the patch called but the stub lacks fails as undefined here rather + // than passing silently. + inkFormLabelPatch.setup( + build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0], + ); + + expect(registered).toHaveLength(1); + const [{ filter, callback }] = registered; + expect(filter.test(submitButtonPath)).toBe(true); + + const { contents } = await callback({ path: submitButtonPath }); + expect(contents).toContain(INK_FORM_INCOMPLETE_HINT.fixed); + expect(contents).not.toContain(INK_FORM_INCOMPLETE_HINT.typo); + }); + + it("scopes the patch to ink-form's SubmitButton and nothing else", () => { + expect(INK_FORM_SUBMIT_BUTTON.test(submitButtonPath)).toBe(true); + // A Windows-style path must match too — the filter runs against whatever + // esbuild resolved, and its separator is the platform's. + expect( + INK_FORM_SUBMIT_BUTTON.test( + "C:\\repo\\node_modules\\ink-form\\lib\\SubmitButton.js", + ), + ).toBe(true); + for (const other of [ + "/repo/node_modules/ink-form/lib/Form.js", + "/repo/node_modules/ink-select-input/build/SubmitButton.js", + "/repo/src/SubmitButton.jsx", + ]) { + expect(INK_FORM_SUBMIT_BUTTON.test(other), other).toBe(false); + } + }); + it("fails loudly rather than silently skipping a label it cannot find", () => { expect(() => fixInkFormIncompleteHint("no such label here", "SubmitButton.js"), diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 3a4c66f7..b75dd86e 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -1,5 +1,4 @@ -import { defineConfig } from "tsup"; -import type { Plugin } from "esbuild"; +import { defineConfig, type Options } from "tsup"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -45,11 +44,25 @@ export function fixInkFormIncompleteHint(source: string, file: string): string { ); } -const inkFormLabelPatch: Plugin = { +/** + * Which module the patch above is applied to. + * + * Exported so the tests can check it against the *resolved* path of the real + * `ink-form` module — a filter that stops matching is the one way this patch + * can silently no-op, since `fixInkFormIncompleteHint` would then never run. + */ +export const INK_FORM_SUBMIT_BUTTON = /ink-form[\\/]lib[\\/]SubmitButton\.js$/; + +// The plugin type is derived from tsup rather than imported from `esbuild`: +// `esbuild` is tsup's transitive dependency, not a declared one of this client, +// so a direct import typechecks only while npm happens to hoist it. +type EsbuildPlugin = NonNullable<Options["esbuildPlugins"]>[number]; + +export const inkFormLabelPatch: EsbuildPlugin = { name: "ink-form-label-patch", setup(build) { build.onLoad( - { filter: /ink-form[\\/]lib[\\/]SubmitButton\.js$/ }, + { filter: INK_FORM_SUBMIT_BUTTON }, async ({ path: file }) => ({ contents: fixInkFormIncompleteHint(await readFile(file, "utf8"), file), loader: "js", From 9fd5bc177cb9e40e651756c533fed14526043bc6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 11 Aug 2026 23:45:09 -0400 Subject: [PATCH 103/941] chore: drop the orphaned root override, and correct the TUI coverage note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1983 (Copilot's suppressed comments). Removing `ink-form` from the root dependencies left `overrides.ink-select-input` with nothing to override: no root dependency reaches that package any more, and the regenerated lock carries no entry for it. The override that matters lives in `clients/tui/package.json`, where the build resolves `ink-form` — and it is what puts `ink-select-input@6` (rather than the React-18-era v5) into the bundle. The README paragraph above the new bundling section still described the Ink components and `App.tsx` as an interim coverage exclusion. #1501 lifted that: `vitest.config.ts` now gates all of `src/**`, excluding only the pure `src/tui-servers.ts` re-export. Corrected to match the config it describes. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- clients/tui/README.md | 14 ++++++++++---- package.json | 3 --- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/clients/tui/README.md b/clients/tui/README.md index 58e8be40..0fb57407 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -102,10 +102,16 @@ The repo-root `validate:tui` just delegates here. `eslint.config.js` registers stricter react-hooks@7 rules are not enforced on the interim component surface (#1501). -Tests live in `__tests__/`. The coverage gate currently covers the TUI's -non-React logic (server resolution, logger, tab metadata, and the `utils/` -form/URL helpers); the Ink components and `App.tsx` are an interim exclusion in -`vitest.config.ts` pending a renderer-based follow-up. +Tests live in `__tests__/`. The coverage gate covers **all of `src/**`**, React +surface included — the Ink components mount through `ink-testing-library` (with +the `ink-scroll-view` / `ink-form` passthrough doubles in `__tests__/helpers/`), +`App.tsx` mounts against a mock of the `@inspector/core` surface, and keypresses +are driven through stdin. The former interim exclusion of the components and +`App.tsx` was lifted in #1501; the only exclusion left in `vitest.config.ts` is +`src/tui-servers.ts`, a pure re-export of core's server resolver with no runtime +statements of its own (its logic is measured in `core/` via the web suite, and +`tui-servers.test.ts` still exercises it behaviorally — it is excluded only so it +doesn't surface as a misleading 0/0 row). ### Bundling: React-rendering dependencies must be inlined (#1952) diff --git a/package.json b/package.json index c09afb9d..6484a82f 100644 --- a/package.json +++ b/package.json @@ -99,9 +99,6 @@ "yaml": "^2.9.0", "zod": "^4.4.3" }, - "overrides": { - "ink-select-input": "^6.2.0" - }, "engines": { "node": ">=22.19.0" }, From 1e0f94bb31a9034159f4b1b5895b01f43ca5869f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:16:05 -0400 Subject: [PATCH 104/941] fix(test): clear leaked timers so one cannot fail an unrelated CI run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window.setTimeout that outlives its test file fires after happy-dom disposes that file's window; React's dispatchSetState then throws an uncaught ReferenceError: window is not defined. Vitest attributes it to whichever file was running, so a single leak fails the ENTIRE run with every test passing — landing on an innocent file under a step named "Enforce per-file coverage gate." Seen twice in CI, both on docs-only branches. Not a Mantine bug, and not a badly-written test. Both hooks do clear on unmount (useTransition's clearAllTimeouts, useLockScroll's effect cleanup). It is a rAF race: clearAllTimeouts cancels the *pending* rAF, but the transition schedules rAF -> rAF -> setTimeout, so if the inner callback is already in flight when the unmount lands, cancelAnimationFrame is a no-op and that callback then schedules a timer after cleanup has run. Nothing owns it. Needs a loaded machine, which is why it only ever appeared in CI. Track every timer and clear whatever is still outstanding after cleanup(). The ordering is load-bearing: legitimate unmount cleanups get their turn first, so only true leaks are dropped. Fake timers pass through untracked, which is correct — a fake timer cannot outlive the environment. Also corrects the claim, in AGENTS.md and in setup.ts's own comment, that env="test" prevents this. It does not: env is read only by Transition.mjs at its render branch, while useTransition runs before that check (hooks cannot be conditional) and still schedules real timers. Measured — opening a <Modal> through renderWithMantine schedules three 200ms timers. Believing that guarantee is why this went unexamined. The regression test is mutation-verified: with the net disabled it fails, with it restored it passes. It deliberately does not assert that Mantine schedules no timers — it does, and that is normal; the contract is that none survive. Closes #1984 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- clients/web/src/test/leakedTimers.test.tsx | 72 ++++++++++++++++++++ clients/web/src/test/setup.ts | 79 ++++++++++++++++++++-- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 clients/web/src/test/leakedTimers.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 5f9e44bc..8584c231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **Side-by-side (`<Name>.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx new file mode 100644 index 00000000..ea51b4be --- /dev/null +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -0,0 +1,72 @@ +/** + * Regression tests for the leaked-timer safety net in `setup.ts` (#1984). + * + * A `window.setTimeout` that outlives its test file fires after happy-dom has + * disposed that file's `window`, and React then throws an uncaught + * `ReferenceError: window is not defined` that fails the whole run — from an + * arbitrary innocent file, with every test passing. These lock down the net that + * prevents it. + * + * Note what is deliberately NOT asserted: that Mantine schedules no timers. It + * does (measured: three 200ms timers when a `Modal` opens, even under + * `env="test"`), and that is the library behaving normally. The contract here is + * only that nothing survives the test that scheduled it. + */ + +import { describe, it, expect, vi } from "vitest"; +import { Modal } from "@mantine/core"; +import { renderWithMantine } from "./renderWithMantine"; + +/** Ids the net is tracking, read back through the wrapper it installed. */ +function scheduleTracked(ms: number): number { + return window.setTimeout(() => {}, ms) as unknown as number; +} + +describe("leaked-timer safety net", () => { + it("wraps window.setTimeout rather than leaving the native one in place", () => { + // If the wrapper were absent the net would silently track nothing, so assert + // the instrumentation exists before relying on the behavior it enables. + expect(window.setTimeout.toString()).not.toContain("[native code]"); + }); + + it("clears a timer the test leaves pending, so it cannot fire later", async () => { + const fired = vi.fn(); + window.setTimeout(fired, 20); + // Deliberately do not clear it: the afterEach net owns it from here. The + // next test asserts it never ran. + leaked.callback = fired; + expect(fired).not.toHaveBeenCalled(); + }); + + it("the previous test's leaked timer never fired", async () => { + // 20ms of real time, comfortably past the leaked timer's deadline. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(leaked.callback).not.toBeNull(); + expect(leaked.callback).not.toHaveBeenCalled(); + }); + + it("clearTimeout stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + const id = scheduleTracked(1000); + expect(() => window.clearTimeout(id)).not.toThrow(); + // Clearing twice must stay a no-op — the net also clears at teardown. + expect(() => window.clearTimeout(id)).not.toThrow(); + }); + + it("a Modal's own transition timers do not survive the test that opened it", () => { + // The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle + // `opened`, and Mantine schedules real timers for the transition and the + // scroll lock. Rendering here is enough — the net clears them at teardown, + // and a regression would surface as the run-killing uncaught ReferenceError + // rather than as a failure of this assertion. + const { rerender } = renderWithMantine( + <Modal opened={false} onClose={() => {}} />, + ); + rerender(<Modal opened={true} onClose={() => {}} />); + expect(document.body).toBeTruthy(); + }); +}); + +/** Carries the leaked callback across the two tests above. */ +const leaked: { callback: ReturnType<typeof vi.fn> | null } = { + callback: null, +}; diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index d818a2ce..c84a313f 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -65,10 +65,15 @@ Object.defineProperty(globalThis, "fetch", { // disable Mantine transitions: `useTransition` only honors reduced motion when // `theme.respectReducedMotion` is true, and Mantine 8 defaults it to `false` // (the project theme doesn't override it). The actual protection against the -// #1760 post-teardown `window is not defined` leak is `env="test"` in -// `renderWithMantine`, which forces transitions synchronous regardless. Tests -// that need specific media results still mock `@mantine/hooks` or stub -// `matchMedia` themselves. +// #1760 post-teardown `window is not defined` leak is the leaked-timer safety +// net below — NOT `env="test"`, which an earlier version of this comment +// claimed. `env` is read only by `Transition.mjs`, and only at its *render* +// branch (`transitionDuration === 0 || env === "test"`); `useTransition()` is +// called before that check, since hooks cannot be conditional, so it still +// schedules real `window.setTimeout`s on every `mounted` change. Measured: +// opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers +// (#1984). Tests that need specific media results still mock `@mantine/hooks` +// or stub `matchMedia` themselves. Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, @@ -91,7 +96,73 @@ Object.defineProperty(window, "matchMedia", { }) as unknown as MediaQueryList, }); +// --------------------------------------------------------------------------- +// Leaked-timer safety net (#1984) +// +// A `window.setTimeout` that outlives its test file fires after happy-dom has +// disposed that file's `window`, and React's `dispatchSetState` then throws an +// uncaught `ReferenceError: window is not defined`. Vitest attributes that to +// whichever file happened to be running, and one such error fails the ENTIRE +// run with every test passing — so it reads as a defect in an innocent file. +// +// Mantine's own hooks do clear their timers on unmount (`useTransition`'s +// `clearAllTimeouts`, `useLockScroll`'s effect cleanup), so this is not a +// library bug. It is a race: `clearAllTimeouts` cancels the *pending* rAF, but +// if that rAF callback is already in flight when the unmount lands, +// `cancelAnimationFrame` is a no-op and the callback goes on to schedule a +// `setTimeout` after cleanup has already run. Nothing owns that timer. It needs +// a loaded machine to hit, which is why it only ever appears in CI. +// +// Rather than chase each component, track every timer and clear whatever is +// still outstanding once the test's own teardown has had its turn. Ordering is +// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount +// cleanups clear their own timers and only true leaks are left. +// +// Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake +// implementation, so nothing is tracked while fake timers are installed — which +// is correct, since a fake timer cannot outlive the environment. +const pendingTimers = new Set<number>(); +const realSetTimeout = window.setTimeout.bind(window); +const realClearTimeout = window.clearTimeout.bind(window); + +window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] +): number => { + const id: number = realSetTimeout( + (...cbArgs: unknown[]) => { + pendingTimers.delete(id); + if (typeof handler === "function") { + handler(...cbArgs); + } + }, + timeout, + ...args, + ); + pendingTimers.add(id); + return id; + // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), + // while the DOM lib types it as `number`. The implementation above honors the + // DOM shape the app codes against; this cast bridges the two declarations, + // which TS cannot relate structurally. +}) as unknown as typeof window.setTimeout; + +window.clearTimeout = ((id?: number): void => { + if (typeof id === "number") { + pendingTimers.delete(id); + } + realClearTimeout(id); + // Same DOM-vs-Node overload mismatch as `setTimeout` above. +}) as unknown as typeof window.clearTimeout; + afterEach(() => { cleanup(); window.localStorage.clear(); + // After cleanup(), anything still pending is a leak — drop it so it cannot + // fire past this file's environment teardown. + for (const id of pendingTimers) { + realClearTimeout(id); + } + pendingTimers.clear(); }); From 94a227b9c12451ee7810f1177e4992ac4713742c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:21:43 -0400 Subject: [PATCH 105/941] fix(tui): widen the root react range so an external ink can't split React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #1983. Copilot was right, and about a hazard this repo had already proven and then documented away: the claim that ink's ">=19" peer "keeps npm from hoisting it next to a React the Inspector couldn't also use" is false. A consumer pinning React 19.0 satisfies ">=19" while `^19.2.4` nests underneath, so ink renders through their React and the bundle through ours — the #1952 crash, one level up, breaking TUI startup rather than just its forms. Bundling ink closes it (verified: with a `createRequire` banner for the inlined CJS, since react-reconciler calls `require("react")` and signal-exit@3 `require("assert")`), but costs ~1.4MB — react-reconciler and yoga-layout come along. The root `react` range closes it for free instead: `^19.0.0` is open to the whole major, so npm can satisfy our React and a consumer's pinned React 19 with one copy, and an external ink resolves the same copy the bundle does. Verified against both consumer trees — the reporter's (React 18 + ink 5) and a pinned React 19.0.0 + ink 6.8.0, which splits under `^19.2.4`. That makes the range load-bearing while looking like an ordinary version bump, so the tests pin it: the root range must equal ink's own declared peer floor. They also assert each exempt package is both external and a root dependency — external means consumers install it, the mirror image of the inlined ones, which must not be root-declared. The docs in all four places now say ink is exempt on cost and never on a peer range, and name that claim as one we got wrong. Closes #1952 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .github/copilot-instructions.md | 2 +- AGENTS.md | 4 +- README.md | 2 +- clients/tui/README.md | 68 +++++++++++++++++------- clients/tui/__tests__/tsupConfig.test.ts | 66 ++++++++++++++++++----- clients/tui/tsup.config.ts | 43 +++++++++------ package-lock.json | 29 +++++----- package.json | 2 +- 8 files changed, 152 insertions(+), 64 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 24367041..48820a00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -77,7 +77,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - **The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) belong in the root `package.json` only.** Adding one to a `clients/*/package.json` is a review finding: Node resolution walks up, so the root install already serves every client, and a per-client entry installs a second copy that drifts (it produced two versions of `ext-apps`, and of the transitive v1 SDK, at once — #1970) and reintroduces the duplicate-copy failure `vitest.shared.mts` has a `dedupe` workaround for. - **The v1 `@modelcontextprotocol/sdk` is not a dependency of this repo** and must not become one. It is a peer of `ext-apps`, present in lock files only. - Dependencies reached only through **root-owned code with no manifest** (`test-servers/src`, `core/`) are declared at the root and aliased to the **repo root** in `vitest.shared.mts` — as `express` and `yaml` are — not to `<client>/node_modules` like the other pins there. -- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree; `ink-form` and `ink-scroll-view` declare `react: ">=18"`, so a consumer's React 18 satisfied them and the TUI ended up with two React instances, crashing on the first hook (#1952). Inlining them (`noExternal` in `clients/tui/tsup.config.ts`) makes their `import "react"` resolve from the build directory like the bundle's own. `clients/tui/__tests__/tsupConfig.test.ts` enforces it; `ink` is the documented exception (unbundlable CJS `signal-exit@3`, and a `">=19"` peer). +- **A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed it in the *consumer's* tree, beside a React satisfying *its* peer range — looser than ours, which is all it takes to split React. `ink-form`/`ink-scroll-view` declare `">=18"`, so a consumer's React 18 satisfies them, the TUI ends up with two React instances, and it crashes on the first hook (#1952). Both are inlined via `noExternal` in `clients/tui/tsup.config.ts`. **`ink` is the one exemption, justified by cost (~1.4MB) — never by a peer range**: flag any claim that `">=19"` keeps npm from misplacing it, which is false and was in this repo once. What keeps it safe is the **root `react` range staying open to the whole major (`^19.0.0`)** so npm can dedupe with a consumer's pinned React 19; treat narrowing that range as reopening the bug. `clients/tui/__tests__/tsupConfig.test.ts` enforces the split, the root-declaration of exempt packages, and that range. - **Which section is a separate question from which manifest.** A package `core/` imports at runtime must be in root **`dependencies`**: client builds externalize npm packages, so a published install resolves them from the root manifest and devDependencies are absent there. Only test/build-only packages (`express`) belong in `devDependencies`. Flag a runtime `core/` import added to `devDependencies` — it passes every local check and breaks the published package. ## Tests and the coverage gate diff --git a/AGENTS.md b/AGENTS.md index 8cbfdcf5..0211f8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,9 @@ The same **placement** rule covers anything reached only through **root-owned co - A package only the tests, the test servers, or the build tooling need belongs in **`devDependencies`** — `express`, added there by #1970. - `yaml` is in `dependencies` today even though its only importer is `test-servers/src/load-config.ts`. Left as-is deliberately (moving it changes what ships, which is not a docs change); if you touch it, confirm no published path reads YAML first. -**A package a client build _inlines_ is not a runtime dependency, and is not declared at the root.** The published tarball carries its code inside that client's bundle, so declaring it at the root only makes consumers install a second, unused copy. `ink-form` and `ink-scroll-view` are the two today: both render React and both declare a loose `react` peer (`">=18"`), which let npm hoist them next to a consumer's React 18 while the Inspector's React 19 nested underneath — two React instances, and the TUI crashed on the first hook they called (#1952). They are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them. The general rule: **a dependency that renders React components must be bundled into the client that uses it**, so its `import "react"` resolves from the build directory alongside the bundle's own; `clients/tui/__tests__/tsupConfig.test.ts` enforces it, and `ink` is the documented exception (unbundlable — its CJS `signal-exit@3` fails ESM interop — and safe, since its `">=19"` peer keeps npm from misplacing it). +**A dependency that renders React components must be bundled into the client that uses it, and is then not a root dependency.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, and npm places a package beside a React satisfying *that package's* peer range — looser than ours in every case here, which is all it takes to split React. `ink-form` and `ink-scroll-view` declare `">=18"`, satisfied by a consumer's React 18 while our React 19 nests underneath: the bundle renders through one React, those packages call hooks on another, and the TUI crashes on the first hook (#1952). Both are inlined by `clients/tui/tsup.config.ts` (`noExternal`) and declared only in `clients/tui/package.json`, where the build resolves them — declaring an inlined package at the root would just make consumers install a second, unused copy. + +**`ink` is the single exemption, and it is justified by cost, not by safety.** Bundling it works but adds ~1.4MB (`react-reconciler` + `yoga-layout`, plus a `createRequire` banner, since inlined CJS calls `require` at runtime and esbuild's ESM interop rejects that without a real `require` in scope). **Never justify an exemption by a peer range** — `ink` briefly carried "its `">=19"` peer keeps npm honest", which is false: a consumer pinning React 19.0 satisfies `">=19"` while a narrower range of ours nests underneath. What actually makes the exemption safe is a *different* lever: the **root `react` range stays open to the whole major (`^19.0.0`)**, so npm can dedupe our React with whatever React 19 a consumer pins and an external `ink` lands on the same copy the bundle uses. Narrowing it (e.g. back to `^19.2.4`) silently reopens the crash for the renderer itself, which breaks TUI *startup*, not just its forms. `clients/tui/__tests__/tsupConfig.test.ts` enforces all of it: React-rendering deps inlined, each exempt package both external and root-declared, and the root range pinned to `ink`'s peer floor. The v1 SDK (`@modelcontextprotocol/sdk`) is **not** a dependency of this repo and must not become one — v2 uses the packages above. It appears in the lock files only as a `"peer": true` entry pulled in by `ext-apps`. diff --git a/README.md b/README.md index 7f952494..55459ad0 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. - **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. -- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours. `ink-form` and `ink-scroll-view` declare a loose `react` peer (`">=18"`), so a project holding React 18 satisfies them and hoists them to its own root while the Inspector's React 19 nests underneath — two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). `ink` is the one React renderer left external — it can't be bundled (CJS `signal-exit@3` fails ESM interop) and its `">=19"` peer keeps npm from misplacing it. `clients/tui/__tests__/tsupConfig.test.ts` guards the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball diff --git a/clients/tui/README.md b/clients/tui/README.md index 0fb57407..1ae2fb51 100644 --- a/clients/tui/README.md +++ b/clients/tui/README.md @@ -121,28 +121,60 @@ choice decides which React instance it gets at runtime, and getting it wrong crashes the TUI in a way nothing in this repo can see. An external package's `import "react"` resolves from wherever npm placed **that -package** — and npm places it next to a React satisfying its own peer range. -`ink-form` and `ink-scroll-view` both accept `react: ">=18"`, so a project that -installs the Inspector alongside its own React 18 satisfies them: they hoist to -that project's root while the Inspector's React 19 nests underneath. The bundle -then renders through React 19 while those two call hooks on React 18, whose -dispatcher is null — so opening a tool test form (or any scroll view) dies with +package** — and npm places it beside a React satisfying *its* peer range, which +is looser than ours in every case here: + +| Package | Its `react` peer | Placed beside a different React when… | +| --- | --- | --- | +| `ink-form`, `ink-scroll-view` | `">=18"` | the consumer has React 18 | +| `ink` | `">=19"` | the consumer pins React 19.0 (our `^19.2.4` then nests) | + +Either way the bundle renders through one React while the external package calls +hooks on another, whose dispatcher is null — so opening a tool test form (or any +scroll view, or in `ink`'s case simply starting the TUI) dies with `TypeError: Cannot read properties of null (reading 'useState')`. -Bundling them removes npm from the decision: their `import "react"` is emitted -into `build/index.js`, so it resolves from the build directory exactly like the -bundle's own. It also pins their transitive deps to what *this* install -resolved — notably `ink-select-input@6` via the `overrides` entry, since npm -ignores a dependency's `overrides` and a consumer install would otherwise pull -the React-18-era v5 that `ink-form` asks for. +`ink-form` and `ink-scroll-view` are therefore **inlined**, which removes npm +from the decision: an inlined package's `import "react"` is emitted into +`build/index.js`, so it resolves from the build directory exactly like the +bundle's own. Inlining also pins transitive deps to what *this* install +resolved, notably `ink-select-input@6` via the `overrides` entry — npm ignores a +dependency's `overrides`, so a consumer install would otherwise pull the +React-18-era v5 that `ink-form` asks for. -`ink` itself stays external: it can't be bundled (its CJS `signal-exit@3` -dependency fails ESM interop with `Dynamic require of "assert" is not -supported`), and it doesn't need to be — its `react` peer is `">=19"`, which -keeps npm from hoisting it next to a React the Inspector couldn't also use. +#### Why `ink` itself is the exception -`__tests__/tsupConfig.test.ts` enforces this: every dependency declaring a -`react` peer must be in `noExternal` unless it is listed as external by design. +`ink` is external for **cost**, not because its `">=19"` peer makes it safe — it +does not, and an earlier revision of this section wrongly claimed it did. +Bundling `ink` works (it was built and verified against both consumer repros) +but adds ~1.4 MB, since `react-reconciler` and `yoga-layout` come with it, plus +a `createRequire` banner: inlined CJS calls `require` at runtime +(`react-reconciler` for `"react"`, `signal-exit@3` for `"assert"`) and esbuild's +interop shim throws `Dynamic require of "x" is not supported` without a real +`require` in scope. A 602 KB bundle beat a 2 MB one. + +What makes the exemption tolerable is a **separate lever**: the root manifest +declares `react: "^19.0.0"` — open to the whole major, rather than pinned at the +version we happen to develop against. That lets npm satisfy our React and a +consumer's pinned React 19.x with a single copy, so an external `ink` resolves +*ours*. Verified against a consumer pinning `react@19.0.0` alongside `ink@6.8.0` +— the case that splits under a narrower range: + +``` +bundle react node_modules/react/index.js +ink -> react node_modules/react/index.js # same copy +``` + +Narrow that range and the exemption turns straight back into #1952, one level +up — breaking TUI startup rather than just its forms. `tsupConfig.test.ts` pins +the root range to `ink`'s own peer floor so it can't drift silently. The +residual after all this is a React **20**-era consumer that also depends on +`ink`; that gets revisited when the Inspector moves to React 20. + +`__tests__/tsupConfig.test.ts` enforces the whole split: every dependency +declaring a `react` peer must be in `noExternal` unless it is listed as external +by design, each exempt package must also be a root dependency (external means +consumers install it), and the root `react` range must stay open to the major. Add a React-rendering dependency, and that test tells you to bundle it. ### The `ink-form` label patch diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index 0cff33ca..a9bfc35f 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -58,12 +58,18 @@ function stringEntries(list: (string | RegExp)[] | undefined): string[] { } /** - * The one React-rendering dependency deliberately left external. + * React-rendering dependencies left external by an explicit trade-off. * - * `ink` cannot be bundled — its CJS `signal-exit@3` dependency fails ESM - * interop ("Dynamic require of \"assert\" is not supported") — and does not - * need to be: its `react` peer is ">=19", so npm cannot hoist it next to a - * React the Inspector itself could not use. + * `ink` is here for its **cost**, not because its peer range makes it safe. + * Bundling it works (verified against both consumer repros) but adds ~1.4MB — + * react-reconciler and yoga-layout come with it — plus a `createRequire` banner + * for the inlined CJS. The smaller tarball won. + * + * The exemption is only tolerable because the root manifest keeps `react` open + * to the whole major (`^19.0.0`), which lets npm dedupe our React with whatever + * React 19 a consumer pins — so an external `ink` resolves the same copy the + * bundle does. The test below pins that relationship: narrowing the root range + * silently reopens #1952 for the renderer itself. */ const EXTERNAL_BY_DESIGN = new Set(["ink"]); @@ -108,7 +114,7 @@ describe("tui tsup config", () => { expect(reactRenderingDependencies().length).toBeGreaterThan(0); }); - it("bundles every React-rendering dependency that can be bundled", () => { + it("bundles every React-rendering dependency that is not exempt", () => { const shouldInline = reactRenderingDependencies().filter( (name) => !EXTERNAL_BY_DESIGN.has(name), ); @@ -123,17 +129,53 @@ describe("tui tsup config", () => { } }); + it("keeps the root react range open to the whole major, so npm can dedupe", () => { + // This is what keeps the one exemption tolerable. `ink` is external, so it + // resolves whatever React npm placed beside it — and npm can only place it + // beside *ours* if our range admits the consumer's React too. Pinning the + // root range above the major floor (say `^19.2.4`) means a consumer holding + // React 19.0 gets a second copy: `ink` renders through theirs, the bundle + // through ours, and the TUI dies at startup on a null dispatcher. + // + // So the root range must start at the same floor `ink`'s own peer does. + const rootReact = ( + JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record<string, string> } + ).dependencies.react; + const inkPeerReact = readPackageJson("ink").peerDependencies?.react ?? ""; + + const floor = /^>=(\d+)\.0\.0$/.exec(inkPeerReact)?.[1]; + expect(floor, `unexpected ink peer range: ${inkPeerReact}`).toBeDefined(); + expect( + rootReact, + `root react must be ^${floor}.0.0 so npm can dedupe with any React ${floor} a consumer pins`, + ).toBe(`^${floor}.0.0`); + }); + + it("keeps each exempt package external, and declared for consumers", () => { + // An external package is not shipped in the bundle, so the root manifest + // has to install it — the mirror image of the inlined ones, which must NOT + // be root dependencies. Getting this backwards breaks the published TUI at + // startup with an unresolved import. + const rootDependencies = JSON.parse( + readFileSync(path.join(clientDir, "..", "..", "package.json"), "utf8"), + ) as { dependencies: Record<string, string> }; + + for (const name of EXTERNAL_BY_DESIGN) { + expect(external, `${name} is external by design`).toContain(name); + expect( + Object.keys(rootDependencies.dependencies), + `${name} is external, so consumers must install it`, + ).toContain(name); + } + }); + it("keeps react itself external, as the single shared instance", () => { expect(external).toContain("react"); expect(noExternal).not.toContain("react"); }); - it("documents each React-rendering package left external", () => { - for (const name of EXTERNAL_BY_DESIGN) { - expect(external, `${name} is external by design`).toContain(name); - } - }); - it("corrects ink-form's misspelled incomplete-form hint", () => { // Read the real dependency, so an `ink-form` upgrade that fixes or rewords // the label fails here — the patch must then be removed, not left silently diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index b75dd86e..2c0197a9 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -81,27 +81,38 @@ export default defineConfig({ sourcemap: false, target: "node22", platform: "node", - // Every package here renders React components, so it MUST share the one - // React instance the bundle imports. Bundling them is what guarantees that: - // an inlined package's `import "react"` is emitted into build/index.js and so - // resolves from *this* directory, exactly like the bundle's own — no consumer - // install layout can point it somewhere else (#1952). Left external, npm is - // free to hoist them next to a *different* React: `ink-form` and - // `ink-scroll-view` declare a loose `react` peer (">=18"), so a consumer - // project holding React 18 satisfies it and gets them hoisted to its own root - // while the Inspector's React 19 nests under it — two React copies, and the + // Every package here renders React components, so it MUST share the one React + // instance the bundle imports. Bundling is what guarantees that: an inlined + // package's `import "react"` is emitted into build/index.js, so it resolves + // from *this* directory exactly like the bundle's own, and no consumer install + // layout can point it elsewhere (#1952). + // + // Left external, npm is free to place a package beside a *different* React, + // because it places one beside a version satisfying that package's own peer + // range — looser than ours in every case here. `ink-form` and + // `ink-scroll-view` accept ">=18", so a consumer's React 18 satisfies them + // while the Inspector's React 19 nests underneath: two React copies, and the // first hook they call reads a null dispatcher ("Cannot read properties of // null (reading 'useState')") the moment a tool test form or a scroll view - // mounts. `__tests__/tsupConfig.test.ts` guards this list. + // mounts. That is the reported crash, and inlining them is its fix. + // + // `__tests__/tsupConfig.test.ts` guards this list. noExternal: [/^@inspector\/core/, "ink-form", "ink-scroll-view"], external: [ - // `react` is deliberately external — it is the single instance everything - // above resolves to, from this build directory. + // `react` is deliberately external — the single instance every inlined + // package above resolves to, from this build directory. "react", - // `ink` stays external too: it cannot be bundled (its CJS `signal-exit@3` - // dependency fails ESM interop with "Dynamic require of \"assert\""), and - // it does not need to be — its `react` peer is ">=19", which keeps npm - // from hoisting it next to a React the Inspector could not also use. + // `ink` is external by a deliberate trade-off, NOT because a ">=19" peer + // makes it safe — it does not, and that claim was wrong here once already. + // Bundling it works (verified) but costs ~1.4MB, since react-reconciler and + // yoga-layout come with it, plus a `createRequire` banner for the inlined + // CJS. The smaller tarball won. + // + // What makes that tolerable is the root manifest's `react: ^19.0.0`: being + // open to the whole major lets npm satisfy our React and a consumer's + // pinned one with a single copy, so an external `ink` resolves *ours*. Narrow + // that range and this exemption turns back into the #1952 crash, one level + // up — `__tests__/tsupConfig.test.ts` guards it. "ink", "open", "commander", diff --git a/package-lock.json b/package-lock.json index 972b65f2..d574fdd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "ink": "^6.0.0", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", @@ -1819,13 +1819,14 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", - "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, "node_modules/escape-html": { @@ -3887,9 +3888,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -4024,9 +4025,9 @@ } }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -4291,9 +4292,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 6484a82f..c52f04c1 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "ink": "^6.0.0", "open": "^10.2.0", "pino": "^9.14.0", - "react": "^19.2.4", + "react": "^19.0.0", "undici": "^8.5.0", "vite": "^8.1.5", "yaml": "^2.9.0", From 24d89d7d0b7be942c039a758483c13fea66d443e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:28:34 -0400 Subject: [PATCH 106/941] fix(test): wait for the dead transport instead of sleeping 300ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectDeadSession spawned a real node subprocess whose only job is to die, then slept a flat 300ms for the death to propagate. That budget has to cover a cold spawn(), node's startup, its exit, the parent observing the close, and onclose marking the session dead — comfortable on an idle box, not under CI contention or a machine running several agents. When the budget was missed the session was still live, so isTransportDead() was false and /api/mcp/send fell through to `await responseWait` for a tools/list reply the dead child can never send. Nothing bounds that wait, so the test hung to the project's full 30s ceiling. The timeout IS the symptom of losing the race, which is why it never presented as a clean assertion failure. Poll the observable condition instead. Two design points that are load-bearing: - The probe is a NOTIFICATION (method, no id). requestIdForSendWait returns undefined for it, so the route never awaits a response and cannot hang. Probing with a request would reproduce the exact bug being fixed. - Not /api/mcp/events, which looks like the natural "transport died" signal: opening that stream on a dead transport calls sessions.delete(sessionId), so the send under test would then answer 404 instead of transport_error. Both terminal branches of the route answer transport_error — the isTransportDead() short-circuit and the catch around a send() that threw on the dead pipe — and either proves the subsequent real send cannot reach the hanging path, so both are valid stop conditions. Verified by reproducing the bug deterministically: with the wait reduced to 0 (the budget always missed) the test times out at 30s; with the poll it passes 10/10. Fixing the helper rather than the test also covers its second call site, the auth-state test, which sits on the same race and had simply not lost it yet. connect-crash.test.ts:147 is deliberately left alone. #1985 proposed giving it the same treatment, but readSseEvents stops at transport_error, which arrives whenever the child dies — so its 200ms sleep cannot hang, and changing it would add risk for no gain. Closes #1985 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../mcp/remote/remote-auth-branches.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 5f992d45..27b5bc6d 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -76,8 +76,63 @@ async function startUnauthorizedUpstream(): Promise<{ return { url: `http://127.0.0.1:${port}/mcp`, server }; } -/** Connect a stdio session whose process crashes almost immediately, then - * give the onclose handler time to mark the session's transport dead. */ +/** + * Poll until the server reports the session's transport as dead (#1985). + * + * The probe is a **notification** — a `method` with no `id` — which matters: + * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never + * awaits a response and cannot hang. Probing with a *request* would reproduce + * the very failure this guards against, since a request sent before the + * transport is marked dead waits forever for a reply the dead child will never + * send. + * + * Either terminal branch of the route answers `kind: "transport_error"` — the + * `isTransportDead()` short-circuit, and the `catch` around a `send()` that + * threw on the dead pipe. Both are fine as a stop condition: each one proves the + * subsequent real send also cannot reach the hanging path. + * + * Deliberately not `/api/mcp/events`: opening that stream on a dead transport + * calls `sessions.delete(sessionId)`, so the send under test would then answer + * 404 instead of the `transport_error` it is asserting. + */ +async function waitForDeadTransport( + h: Harness, + sessionId: string, + timeoutMs = 10_000, +): Promise<void> { + const deadline = Date.now() + timeoutMs; + let last = "(no response)"; + while (Date.now() < deadline) { + const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId, + message: { jsonrpc: "2.0", method: "notifications/initialized" }, + }), + }); + const body = (await res.json()) as { kind?: string }; + if (body.kind === "transport_error") return; + last = `HTTP ${res.status} ${JSON.stringify(body)}`; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error( + `transport was never reported dead within ${timeoutMs}ms; last probe: ${last}`, + ); +} + +/** + * Connect a stdio session whose process crashes almost immediately, then wait + * until the transport is actually marked dead. + * + * This used to sleep a flat 300ms, which had to cover a cold `spawn()`, node's + * startup, its exit, the parent observing the close, and `onclose` marking the + * session dead. Comfortable on an idle machine; not under CI contention. When + * the budget was missed the session was still live, so the send under test was + * dispatched to a transport whose child was gone and hung for the project's full + * 30s timeout (#1985). Waiting on the observable condition costs latency on a + * slow machine instead of a false failure. + */ async function connectDeadSession(h: Harness): Promise<string> { const config: MCPServerConfig = { type: "stdio", @@ -87,9 +142,7 @@ async function connectDeadSession(h: Harness): Promise<string> { const res = await connect(h, config); expect(res.status).toBe(200); const { sessionId } = (await res.json()) as { sessionId: string }; - // Give the subprocess time to exit and the transport's onclose handler - // to mark the session dead (mirrors connect-crash.test.ts's technique). - await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForDeadTransport(h, sessionId); return sessionId; } From c4e8ed992bccc82fd1ebc9d988a4a3d84cb4b047 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 00:58:44 -0400 Subject: [PATCH 107/941] fix(test): cancel queued frames before sweeping timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the net still had the exact hole it was written to close. The afterEach is synchronous, so a queued rAF callback cannot run until it returns — at which point it registers a fresh setTimeout AFTER the sweep has already drained. On a file's last test that timer survives environment teardown. That is the rAF race described in the previous commit message, left open by the fix for it. Track animation frames too, and cancel them BEFORE sweeping timers. Order is the fix: JS is single-threaded and nothing between the two loops yields, so no frame callback can run in between. Mutation-checked — without the cancel pass the new ordering test fails (1 failed | 6 passed); with it, 7 pass. Also from review: - renderWithMantine.tsx still documented the disproven env="test" behavior. It is the file contributors are told to use, so leaving it would have preserved the wrong belief exactly where it does most damage. That was the third copy of this claim in the repo; all three now corrected. - Dropped an unjustified `as unknown as` in the test helper, which broke the repo's own double-cast rule. Inference keeps the handle type clearTimeout accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/test/leakedTimers.test.tsx | 31 ++++++++++++-- clients/web/src/test/renderWithMantine.tsx | 21 ++++++--- clients/web/src/test/setup.ts | 50 +++++++++++++++++++--- 3 files changed, 86 insertions(+), 16 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index ea51b4be..209efa25 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -17,9 +17,10 @@ import { describe, it, expect, vi } from "vitest"; import { Modal } from "@mantine/core"; import { renderWithMantine } from "./renderWithMantine"; -/** Ids the net is tracking, read back through the wrapper it installed. */ -function scheduleTracked(ms: number): number { - return window.setTimeout(() => {}, ms) as unknown as number; +/** Schedule through the wrapper the net installed, keeping its inferred handle + * type so it stays directly acceptable to `window.clearTimeout`. */ +function scheduleTracked(ms: number) { + return window.setTimeout(() => {}, ms); } describe("leaked-timer safety net", () => { @@ -52,6 +53,27 @@ describe("leaked-timer safety net", () => { expect(() => window.clearTimeout(id)).not.toThrow(); }); + it("queues a frame that would schedule a timer after the sweep", () => { + // The rAF race in miniature, and the ordering the net depends on. This + // synchronous test cannot let the frame callback run — it fires only after + // the test (and the whole afterEach) returns. If frames were not cancelled + // *before* the timer sweep, this callback would register `rafScheduled` + // after the drain and survive teardown. The next test proves it does not. + requestAnimationFrame(() => { + rafRace.ranFrame = true; + window.setTimeout(() => { + rafRace.ranTimer = true; + }, 5); + }); + expect(rafRace.ranFrame).toBe(false); + }); + + it("neither the queued frame nor its timer ever ran", async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(rafRace.ranFrame).toBe(false); + expect(rafRace.ranTimer).toBe(false); + }); + it("a Modal's own transition timers do not survive the test that opened it", () => { // The real-world shape of #1984: ServerRemoveConfirmModal-style tests toggle // `opened`, and Mantine schedules real timers for the transition and the @@ -70,3 +92,6 @@ describe("leaked-timer safety net", () => { const leaked: { callback: ReturnType<typeof vi.fn> | null } = { callback: null, }; + +/** Records whether a queued frame — or the timer it would register — ever ran. */ +const rafRace = { ranFrame: false, ranTimer: false }; diff --git a/clients/web/src/test/renderWithMantine.tsx b/clients/web/src/test/renderWithMantine.tsx index 61c91077..ea291c6b 100644 --- a/clients/web/src/test/renderWithMantine.tsx +++ b/clients/web/src/test/renderWithMantine.tsx @@ -37,13 +37,20 @@ function makeWrapper(env: "test" | "default", colorScheme: MantineColorScheme) { }; } -// Default render helper. `env="test"` makes Mantine render transitions -// synchronously (no internal `setTimeout`). Without it, a `Transition`/`Modal` -// open/close timer can fire after happy-dom tears down `window` at the end of -// the run, throwing an uncaught `ReferenceError: window is not defined` that -// fails the whole run even when every assertion passed (#1760). This is the -// right default for the vast majority of tests, which don't assert on -// mid-transition state. +// Default render helper. `env="test"` makes Mantine skip the *animated render* +// of a transition, which is the right default for the vast majority of tests — +// they don't assert on mid-transition state. +// +// It does NOT stop the timers, contrary to what this comment used to claim +// (#1984). `env` is read only by `Transition.mjs`, at its render branch +// (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs +// before that check — hooks cannot be conditional — and still schedules real +// `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` +// through this helper schedules three 200ms timers. A timer that outlives its +// file fires after happy-dom disposes that file's `window` and throws an +// uncaught `ReferenceError: window is not defined`, failing the whole run from +// an arbitrary innocent file (#1760). What actually prevents that is the +// leaked-timer safety net in `setup.ts`. export function renderWithMantine( ui: ReactElement, options?: MantineRenderOptions, diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index c84a313f..a564fa5d 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -113,17 +113,23 @@ Object.defineProperty(window, "matchMedia", { // `setTimeout` after cleanup has already run. Nothing owns that timer. It needs // a loaded machine to hit, which is why it only ever appears in CI. // -// Rather than chase each component, track every timer and clear whatever is -// still outstanding once the test's own teardown has had its turn. Ordering is -// load-bearing: this runs *after* `cleanup()` below, so legitimate unmount -// cleanups clear their own timers and only true leaks are left. +// Rather than chase each component, track every timer *and every animation +// frame*, and clear whatever is still outstanding once the test's own teardown +// has had its turn. Ordering is load-bearing twice over: this runs *after* +// `cleanup()` below, so legitimate unmount cleanups clear their own timers and +// only true leaks are left; and frames are cancelled *before* timers are swept, +// because a queued frame callback would otherwise run after the sweep and +// register a fresh timer. See the ordering note on the `afterEach` itself. // // Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake // implementation, so nothing is tracked while fake timers are installed — which // is correct, since a fake timer cannot outlive the environment. const pendingTimers = new Set<number>(); +const pendingFrames = new Set<number>(); const realSetTimeout = window.setTimeout.bind(window); const realClearTimeout = window.clearTimeout.bind(window); +const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); +const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); window.setTimeout = (( handler: TimerHandler, @@ -156,11 +162,43 @@ window.clearTimeout = ((id?: number): void => { // Same DOM-vs-Node overload mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; +window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { + const handle: number = realRequestAnimationFrame((time: number) => { + pendingFrames.delete(handle); + callback(time); + }); + pendingFrames.add(handle); + return handle; + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.requestAnimationFrame; + +window.cancelAnimationFrame = ((handle: number): void => { + pendingFrames.delete(handle); + realCancelAnimationFrame(handle); + // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +}) as unknown as typeof window.cancelAnimationFrame; + afterEach(() => { cleanup(); window.localStorage.clear(); - // After cleanup(), anything still pending is a leak — drop it so it cannot - // fire past this file's environment teardown. + + // Order is the whole point, and it is not interchangeable. + // + // Cancel queued animation frames FIRST. This `afterEach` is synchronous, so a + // frame callback already queued cannot run until it returns — at which point + // it would register a fresh `setTimeout` *after* the sweep below had already + // drained, and on the file's last test that timer would survive teardown. + // That is precisely the rAF race this net exists for, so sweeping timers + // without cancelling frames first would leave the original hole open. + // + // Cancelling first closes it deterministically: JS is single-threaded and + // nothing here yields, so no frame callback can run between these two loops. + for (const handle of pendingFrames) { + realCancelAnimationFrame(handle); + } + pendingFrames.clear(); + + // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { realClearTimeout(id); } From b70aed691418a7e2e98c897cba6ab44ad422f648 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:10:03 -0400 Subject: [PATCH 108/941] test(fix): probe a single-source route when waiting for the dead transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #1987: `waitForDeadTransport` polled `/api/mcp/send`, which answers `transport_error` from two branches — the `isTransportDead()` short-circuit and the `catch` around a rejected `send()`. Only the first implies the transport is marked dead, so the stop condition could not tell them apart from the response alone. That was safe in practice: `StdioClientTransport.send` rejects only when `_process` is undefined, and the transport clears `_process` and calls `onclose` in one synchronous block, so the `catch` branch is not observable before `markTransportDead()` has run (confirmed empirically — 0/25 probes returned early). But the safety rested on an SDK-internal ordering this test neither states nor controls, and if it changed the send test would silently cover the `catch` branch instead of the short-circuit it names — a regression with no failing run attached to it. Poll `/api/mcp/auth-state` instead. For a session connected without an authState there is no auth provider, so `setAuthState` always throws and `transport_error` there has exactly one source: the dead-transport short-circuit. The route never awaits a transport response, so it cannot hang either. Same cost, exact condition, no dependence on SDK internals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLYmVPVLdeYpE6aMTUsCks --- .../mcp/remote/remote-auth-branches.test.ts | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts index 27b5bc6d..355f5bb8 100644 --- a/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remote-auth-branches.test.ts @@ -79,17 +79,31 @@ async function startUnauthorizedUpstream(): Promise<{ /** * Poll until the server reports the session's transport as dead (#1985). * - * The probe is a **notification** — a `method` with no `id` — which matters: - * `requestIdForSendWait` returns `undefined` for it, so `/api/mcp/send` never - * awaits a response and cannot hang. Probing with a *request* would reproduce - * the very failure this guards against, since a request sent before the - * transport is marked dead waits forever for a reply the dead child will never - * send. + * The probe is `/api/mcp/auth-state`, chosen because for a session connected + * *without* an authState it answers `kind: "transport_error"` from exactly one + * place — the `isTransportDead()` short-circuit. Every other outcome is a 400 + * from `setAuthState` ("Session has no OAuth auth provider"), so a + * `transport_error` here is proof that `onclose` has already run + * `markTransportDead()`, not merely that some write failed. * - * Either terminal branch of the route answers `kind: "transport_error"` — the - * `isTransportDead()` short-circuit, and the `catch` around a `send()` that - * threw on the dead pipe. Both are fine as a stop condition: each one proves the - * subsequent real send also cannot reach the hanging path. + * That single source is the point. `/api/mcp/send` looks like the obvious probe + * but answers `transport_error` from *two* branches: the dead-transport + * short-circuit, and the `catch` around a `send()` that rejected. Only the first + * implies the transport is marked dead, so a stop condition keyed on the + * response cannot tell them apart. It happens to be safe today — + * `StdioClientTransport.send` rejects only when `_process` is undefined, and the + * transport clears `_process` and calls `onclose` in one synchronous block, so + * the `catch` branch cannot be observed before `markTransportDead()` has run — + * but that is an SDK-internal ordering detail, not something this test states or + * controls. If it ever changed, an early return here would hand each call site a + * live session: the send test would quietly exercise the `catch` branch instead + * of the short-circuit it names, and the auth-state test would fall through to + * `setAuthState` and fail on a 400. Probing a single-source route costs nothing + * and does not depend on the ordering holding. + * + * The route also cannot hang: it never awaits a response from the transport, so + * unlike a *request* through `/api/mcp/send` there is nothing here to wait + * forever on a reply the dead child will never send. * * Deliberately not `/api/mcp/events`: opening that stream on a dead transport * calls `sessions.delete(sessionId)`, so the send under test would then answer @@ -103,12 +117,14 @@ async function waitForDeadTransport( const deadline = Date.now() + timeoutMs; let last = "(no response)"; while (Date.now() < deadline) { - const res = await fetch(`${h.baseUrl}/api/mcp/send`, { + const res = await fetch(`${h.baseUrl}/api/mcp/auth-state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, - message: { jsonrpc: "2.0", method: "notifications/initialized" }, + authState: { + oauthTokens: { access_token: "probe", token_type: "Bearer" }, + }, }), }); const body = (await res.json()) as { kind?: string }; From b451949c9e446d8ceeece527d2e8cf58d091dcb3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:11:05 -0400 Subject: [PATCH 109/941] docs: the renderWithMantine rule no longer rests on timer safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a second-order effect of the fix: making the leaked-timer net global invalidated the rationale of a neighbouring rule. "Do not hand-roll a bare MantineProvider — that reintroduces the leak class" stopped being true the moment the net moved into setup.ts, which covers every unit test regardless of how it renders. The rule itself is still right, so keep it and replace the justification: a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Both copies say explicitly that the old reason no longer holds, so nobody re-derives it. Mirrored into .github/copilot-instructions.md in the same PR, as AGENTS.md requires for review-relevant guidance — it carried the identical stale claim. That makes four places this one belief had propagated to. A fix can create stale docs by making a previously-true statement false, which is easy to miss when only the changed lines get read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0628741c..a0a2ddae 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,7 +92,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ### Rendering components in tests -- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. Never hand-roll a bare `MantineProvider` — that reintroduces a real failure class where a `Transition`/`Modal` timer fires after happy-dom tears down `window` and fails the entire run. +- **Always render through `renderWithMantine`** from `src/test/renderWithMantine.tsx`. A hand-rolled bare `MantineProvider` skips the project theme and the helper's options, and drifts from every other test. (It does *not* reintroduce the timer-leak class — an older version of this rule said so; the leaked-timer net in `setup.ts` is global and covers every unit test regardless of how it renders.) - For a forced color scheme, pass the option — `renderWithMantine(ui, { colorScheme: "dark" })` — rather than a hand-rolled `defaultColorScheme` provider. - Only when asserting _mid-flight_ transition state, use `renderWithMantineTransitions`, passing `settleMs` derived from the component's real animation duration. Do **not** combine it with `vi.useFakeTimers()`, and use the `unmount()` it returns if the test unmounts the tree itself. diff --git a/AGENTS.md b/AGENTS.md index 8584c231..3fd9988b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -635,7 +635,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **Side-by-side (`<Name>.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776). - **`src/test/` — the three things that _cannot_ be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement _is_ the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests _of_ a source file, so nothing to sit beside. - **The above is web only.** The Node clients (**cli, tui, launcher**) keep **all** their tests in a top-level **`__tests__/`** dir, not beside their source — their `tsconfig.json` excludes `**/*.test.*` and their `tsconfig.test.json` includes `__tests__/**/*` (plus, for launcher, its root `vitest.config.ts`), so a co-located `src/**/*.test.*` lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage` (#1791). Put a new cli/tui/launcher test under `__tests__/`. -- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. +- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"`, which makes Mantine skip the transition's animated **render** — but be clear on what that does *not* buy you: it does **not** stop the timers. `env` is read only by `Transition.mjs`, at its render branch (`transitionDuration === 0 || env === "test"`), while `useTransition()` runs before that check (hooks cannot be conditional) and still schedules real `window.setTimeout`s on every `mounted` change. Measured: opening a `<Modal>` through `renderWithMantine` schedules three 200ms timers (#1984). A timer that outlives its file fires after happy-dom disposes that file's `window`, and React's `dispatchSetState` then throws an uncaught `ReferenceError: window is not defined` that fails the **whole run** — attributed to whichever file was running, so it lands on an innocent one (#1760). What actually prevents that is the **leaked-timer safety net in `src/test/setup.ts`**, which tracks every timer and clears whatever is still pending after `cleanup()`; see the comment there for the rAF race that makes Mantine's own unmount cleanup insufficient. **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** — a hand-rolled provider skips the project theme and the helper's options and drifts from every other test. Note this rule used to be justified by the leak class, which is now wrong: the `setup.ts` net is global and covers every unit test however it renders. The rule stands on consistency, not on timer safety. To exercise a **forced color scheme** (e.g. the `useComputedColorScheme` dark branch) pass the `colorScheme` option — `renderWithMantine(ui, { colorScheme: "dark" })` — instead of hand-rolling a `defaultColorScheme="dark"` provider (#1786). Only when a test must assert _mid-flight_ transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions). Such a test can leak the #1760 class because waiting for one cell to unmount does **not** settle a concurrent _enter_ (a completed enter leaves no DOM signal to `waitFor`), so the helper **automatically drains the in-flight animation after the test**. The rule for using it: pass `settleMs` derived from the component's real animation duration — its `Transition` `duration`/`exitDuration` plus any `enterDelay`/`exitDelay` plus rAF slack — e.g. `renderWithMantineTransitions(ui, { settleMs: HEADER_ANIM_MS + 200 })` (so the window can't silently become insufficient when that duration changes); do **not** also use `vi.useFakeTimers()` in the same test (the auto-settle no-ops under fake timers — it warns, but anything the test left pending on the _real_ clock is then unprotected, so the test depends on which clock was installed at teardown); and if the test unmounts the tree itself, use the `unmount()` the helper returns (it drops that tree from the settle's liveness check, while still draining — a bare mid-body `cleanup()` on a still-armed tree would trip the check). The mechanism behind all three — why the drain is `act`-wrapped, the fake-timer hazard, the `afterEach`-before-`cleanup()` ordering and its `container.isConnected` self-checks, and the exported `settleTransitions(ms)` for manual mid-body settling — is documented at length on the helper in `renderWithMantine.tsx`; read there before changing it. ### Responding to Code Reviews From c0e4b89c8de0d2322ea09b434942c59e01ec6c75 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 01:23:21 -0400 Subject: [PATCH 110/941] fix(test): treat timer handles as opaque; the numeric guard never matched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review was right, and I had assumed the handle type rather than checking it. Probed both under happy-dom: setTimeout -> typeof "object", constructor Timeout requestAnimationFrame -> typeof "object", constructor Immediate So `typeof id === "number"` in the clearTimeout wrapper never matched, and an explicitly-cleared timer was never untracked. Both `Set<number>` annotations and `const id: number` were fictions TypeScript could not catch, since the DOM lib DECLARES number while happy-dom RETURNS an object. The effect was benign — the teardown sweep re-clears them and clearTimeout is idempotent — but the sharper problem is that the test asserting this passed for the wrong reason. "clearTimeout doesn't throw" holds whether or not tracking works, so it proved nothing and hid the broken guard. Hold handles as `unknown`, route cancellation through two small helpers that carry the one justified cast each, and drop the numeric guards. The test now asserts the net's own bookkeeping through an exported pendingTimerCount(), and is mutation-checked: restoring the numeric guard fails it (1 failed | 6 passed). That is the second test in this PR that passed without proving its claim. Both had the same tell — the assertion would have held with the feature removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/test/leakedTimers.test.tsx | 15 +++-- clients/web/src/test/setup.ts | 74 ++++++++++++++++------ 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/clients/web/src/test/leakedTimers.test.tsx b/clients/web/src/test/leakedTimers.test.tsx index 209efa25..65445338 100644 --- a/clients/web/src/test/leakedTimers.test.tsx +++ b/clients/web/src/test/leakedTimers.test.tsx @@ -16,6 +16,7 @@ import { describe, it, expect, vi } from "vitest"; import { Modal } from "@mantine/core"; import { renderWithMantine } from "./renderWithMantine"; +import { pendingTimerCount } from "./setup"; /** Schedule through the wrapper the net installed, keeping its inferred handle * type so it stays directly acceptable to `window.clearTimeout`. */ @@ -46,11 +47,17 @@ describe("leaked-timer safety net", () => { expect(leaked.callback).not.toHaveBeenCalled(); }); - it("clearTimeout stops tracking, so an explicitly-cleared timer is not double-cleared", () => { + it("clearTimeout untracks the timer", () => { + // Asserted against the net's own bookkeeping, not against "clearTimeout + // doesn't throw" — the latter holds whether or not tracking works, since + // clearTimeout is idempotent, so it proved nothing. That vacuity hid a real + // bug: happy-dom's handle is a `Timeout` object, so the original + // `typeof id === "number"` guard never matched and nothing was untracked. + const before = pendingTimerCount(); const id = scheduleTracked(1000); - expect(() => window.clearTimeout(id)).not.toThrow(); - // Clearing twice must stay a no-op — the net also clears at teardown. - expect(() => window.clearTimeout(id)).not.toThrow(); + expect(pendingTimerCount()).toBe(before + 1); + window.clearTimeout(id); + expect(pendingTimerCount()).toBe(before); }); it("queues a frame that would schedule a timer after the sweep", () => { diff --git a/clients/web/src/test/setup.ts b/clients/web/src/test/setup.ts index a564fa5d..c86b3634 100644 --- a/clients/web/src/test/setup.ts +++ b/clients/web/src/test/setup.ts @@ -124,19 +124,48 @@ Object.defineProperty(window, "matchMedia", { // Under `vi.useFakeTimers()` the wrapper is swapped out for vitest's fake // implementation, so nothing is tracked while fake timers are installed — which // is correct, since a fake timer cannot outlive the environment. -const pendingTimers = new Set<number>(); -const pendingFrames = new Set<number>(); +// Handles are held as `unknown`, deliberately. The DOM lib declares these as +// `number`, but happy-dom returns objects at runtime — verified here: +// `setTimeout` yields a `Timeout` and `requestAnimationFrame` an `Immediate`. +// An earlier revision typed the sets `Set<number>` and guarded removal with +// `typeof id === "number"`; that guard never matched, so explicitly-cleared +// timers were never untracked. Harmless in effect (the teardown sweep clears +// them again and `clearTimeout` is idempotent) but the bookkeeping was a +// fiction, and the test asserting it passed vacuously. Treat the handle as +// opaque and pass it straight back to the matching canceller. +const pendingTimers = new Set<unknown>(); +const pendingFrames = new Set<unknown>(); const realSetTimeout = window.setTimeout.bind(window); const realClearTimeout = window.clearTimeout.bind(window); const realRequestAnimationFrame = window.requestAnimationFrame.bind(window); const realCancelAnimationFrame = window.cancelAnimationFrame.bind(window); +/** Cancel an opaque handle through the real canceller it came from. */ +function cancelTimer(id: unknown): void { + // The handle came from `realSetTimeout`, so it is exactly what + // `realClearTimeout` expects; only the *declared* type disagrees. + realClearTimeout(id as Parameters<typeof realClearTimeout>[0]); +} + +function cancelFrame(handle: unknown): void { + // As above, for the rAF pair. + realCancelAnimationFrame( + handle as Parameters<typeof realCancelAnimationFrame>[0], + ); +} + +/** Test-only introspection, so the regression tests can assert on the + * bookkeeping itself rather than on behavior that would hold anyway. */ +export function pendingTimerCount(): number { + return pendingTimers.size; +} + window.setTimeout = (( handler: TimerHandler, timeout?: number, ...args: unknown[] -): number => { - const id: number = realSetTimeout( +): unknown => { + const id: unknown = realSetTimeout( (...cbArgs: unknown[]) => { pendingTimers.delete(id); if (typeof handler === "function") { @@ -148,34 +177,37 @@ window.setTimeout = (( ); pendingTimers.add(id); return id; - // happy-dom types `setTimeout` with Node's overloads (returning `Timeout`), - // while the DOM lib types it as `number`. The implementation above honors the - // DOM shape the app codes against; this cast bridges the two declarations, - // which TS cannot relate structurally. + // The wrapper's public signature must match the DOM declaration the app codes + // against, while its body traffics in the runtime handle described above. TS + // cannot relate the two, hence the cast. }) as unknown as typeof window.setTimeout; -window.clearTimeout = ((id?: number): void => { - if (typeof id === "number") { +window.clearTimeout = ((id?: unknown): void => { + // No `typeof` guard: the handle is an object here, so a numeric test would + // reject every real one. Anything defined is worth untracking. + if (id !== undefined && id !== null) { pendingTimers.delete(id); } - realClearTimeout(id); - // Same DOM-vs-Node overload mismatch as `setTimeout` above. + cancelTimer(id); + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.clearTimeout; -window.requestAnimationFrame = ((callback: FrameRequestCallback): number => { - const handle: number = realRequestAnimationFrame((time: number) => { +window.requestAnimationFrame = ((callback: FrameRequestCallback): unknown => { + const handle: unknown = realRequestAnimationFrame((time: number) => { pendingFrames.delete(handle); callback(time); }); pendingFrames.add(handle); return handle; - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.requestAnimationFrame; -window.cancelAnimationFrame = ((handle: number): void => { - pendingFrames.delete(handle); - realCancelAnimationFrame(handle); - // Same DOM-vs-happy-dom declaration mismatch as `setTimeout` above. +window.cancelAnimationFrame = ((handle?: unknown): void => { + if (handle !== undefined && handle !== null) { + pendingFrames.delete(handle); + } + cancelFrame(handle); + // Same declaration-vs-runtime mismatch as `setTimeout` above. }) as unknown as typeof window.cancelAnimationFrame; afterEach(() => { @@ -194,13 +226,13 @@ afterEach(() => { // Cancelling first closes it deterministically: JS is single-threaded and // nothing here yields, so no frame callback can run between these two loops. for (const handle of pendingFrames) { - realCancelAnimationFrame(handle); + cancelFrame(handle); } pendingFrames.clear(); // Then drop the timers. After cleanup(), anything still pending is a leak. for (const id of pendingTimers) { - realClearTimeout(id); + cancelTimer(id); } pendingTimers.clear(); }); From 3e76498366ebe3dd46867084cbc2e38d28cab6d3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 08:34:08 -0400 Subject: [PATCH 111/941] chore: upgrade the MCP TypeScript SDK from 2.0.0-beta.5 to 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK shipped 2.0.0 final on 2026-07-27. Shipping the Inspector against a prerelease of its core protocol library is a supply-chain and reproducibility liability we no longer have a reason to carry: a beta is eligible for unpublish and deprecation in a way a stable release is not. Bumped in the root package.json only — client, core, server, server-legacy. Node resolution walks up, so the root install already serves every client; a per-client declaration would install a second copy that drifts from it (#1970). Verified after install: one copy of each at 2.0.0, no clients/*/node_modules/ @modelcontextprotocol/* at all. ext-apps is a separate package on its own ^1.7.4 line and is untouched. The beta.5 → 2.0.0 delta is seven commits. Reviewed each for call-site impact: - core/src/schemas.ts (+16/-16) is doc-comment only — every hunk rewrites a spec URL from a commit-pinned GitHub blob to the published 2026-07-28 page. No schema surface change, so nothing in core/mcp or core/json is affected. - client 2.0.0 wants zod ^4.2.0; we are on ^4.4.3, so no zod move and no risk of the #1896 dual-copy tsc heap blowup. verify:dep-lockstep stays green and the lockfile delta is exactly the four packages with no transitive churn. - fix(validators) honors declared draft-07/06 JSON Schema dialects instead of rejecting them, which can only widen the set of tool schemas we accept. - The SSE keep-alive and legacyWrap changes are server-side, exercised by the test servers rather than by client code. The one change with real behavioral reach is #2564: the negotiation probe now classifies HTTP 401/403 as an auth failure instead of legacy-era evidence. That is the upstream fix #1807 is blocked on, and it is left for that issue — the directAuthRecovery clause in inspectorClient.ts still intercepts the challenge before the classifier sees it, so this bump does not change connect behavior. #1807 can now delete the workaround and lean on the SDK verdict, but doing it here would smuggle an auth change into a dependency bump. Also corrected the two auth as-built specs, which still claimed beta.4 — already stale before this change. v2_new_spec_impact.md is deliberately left alone: it is a dated pre-upgrade analysis, and editing its premises would rewrite the record rather than update it. npm run ci passes end to end: validate, the per-file >=90 coverage gate, verify:build-gate, all six smokes (including web:app driving connect -> open app -> data-app-status="ready"), and 472 Storybook tests. Closes #1988 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- package-lock.json | 38 +++++++++++----------- package.json | 8 ++--- specification/v2_auth_hardening.md | 2 +- specification/v2_auth_sdk_consolidation.md | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index d574fdd9..5b738bc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,11 @@ "license": "MIT", "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", @@ -299,12 +299,12 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0-beta.5.tgz", - "integrity": "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", @@ -317,9 +317,9 @@ } }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0-beta.5.tgz", - "integrity": "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", "license": "MIT", "dependencies": { "zod": "^4.2.0" @@ -399,12 +399,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0-beta.5.tgz", - "integrity": "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" }, "engines": { @@ -412,13 +412,13 @@ } }, "node_modules/@modelcontextprotocol/server-legacy": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0-beta.5.tgz", - "integrity": "sha512-8BemN4avQnG6Fu660fZCqnPGpeyL7gg5kxUceZQh7JCt8oqzX1bwJkNV+cKS01LPAaPbl93INneD+mBtJWKWvQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha512-LnffC1BSqFMHtMQxEz92lqDpHWma+ErV3ghdHDgdkCyYzVcCYKcUT5loq4kflty+Bf9C9qjJqbnphyBWyCqo8Q==", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", "dependencies": { - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/core": "2.0.0", "content-type": "^1.0.5", "cors": "^2.8.5", "express-rate-limit": "^8.2.1", diff --git a/package.json b/package.json index c52f04c1..b255bb28 100644 --- a/package.json +++ b/package.json @@ -78,11 +78,11 @@ }, "dependencies": { "@hono/node-server": "^2.0.12", - "@modelcontextprotocol/client": "2.0.0-beta.5", - "@modelcontextprotocol/core": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", - "@modelcontextprotocol/server-legacy": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", "@vitejs/plugin-react": "^6.0.0", "ajv": "^8.17.1", diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 1595bc9e..78eb26b1 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -6,7 +6,7 @@ As-built status for aligning Inspector with the **authorization hardening** SEPs in the MCP **`2026-07-28`** release — tracked by [#1527](https://github.com/modelcontextprotocol/inspector/issues/1527). -Inspector is on `@modelcontextprotocol/client` **2.0.0-beta.4**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). +Inspector is on `@modelcontextprotocol/client` **2.0.0**. Connect-time standard OAuth is delegated to SDK `auth()`; Inspector owns storage, callbacks, remoting, EMA host flow, and mid-session UX. See [SDK consolidation](v2_auth_sdk_consolidation.md). **Policy:** SEP behavior that can be automated is covered (or should be covered) by unit/integration tests. Hosted-IdP smoke in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) is complementary for real providers — it is **not** required for every SEP once CI covers the requirement. diff --git a/specification/v2_auth_sdk_consolidation.md b/specification/v2_auth_sdk_consolidation.md index f3711bd7..9c044911 100644 --- a/specification/v2_auth_sdk_consolidation.md +++ b/specification/v2_auth_sdk_consolidation.md @@ -4,7 +4,7 @@ #### [Overview](v2_auth.md) | [EMA / XAA](v2_auth_ema.md) | [Hardening](v2_auth_hardening.md) | [Mid-session](v2_auth_mid_session.md) | [Smoke testing](v2_auth_smoke_testing.md) | SDK consolidation -Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0-beta.4** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. +Record of how Inspector uses `@modelcontextprotocol/client` **2.0.0** for authorization after the v2 SDK upgrade: what we moved onto the SDK, what we left Inspector-owned and why, and which small SDK API gaps would let us delete more local wire later. Related as-built specs: [Hardening](v2_auth_hardening.md), [EMA](v2_auth_ema.md), [Mid-session](v2_auth_mid_session.md). From 55878297d9bde86ed4de253350db6a0dc776f18b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 09:46:45 -0400 Subject: [PATCH 112/941] docs: correct the EMA spec's stale SDK-dependency claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that v2_auth_ema.md contradicted itself. Its "TypeScript SDK (implemented)" section claimed Inspector depends on `@modelcontextprotocol/sdk` v1.x only (`^1.29.0`) and that there is "no `@modelcontextprotocol/client` v2 dependency in the tree today" — while the subsection immediately below it documents which v2 client helpers the EMA legs have adopted. Checked rather than assumed: every module the section's table names imports the v2 client (emaFlow, idpOidc, resourceContext, tokenEndpoint, transportProvider, wire, and providers.ts), and there are zero v1 SDK imports in first-party code. The v1 SDK is present in the tree only as a peer pulled in by ext-apps, which AGENTS.md already states must never become a direct dependency. Rewrote the paragraph to say what is true and name the version, so it stays consistent with the two auth specs corrected in the previous commit. The per-row "v1 SDK" attributions in the table below it are wrong for the same reason, but correcting each one means auditing which SDK symbol each leg calls today — a real audit, not a version-string refresh, and out of scope for a dependency bump. Left for a follow-up rather than guessed at here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- specification/v2_auth_ema.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 6f314d1f..a7a4c917 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -46,7 +46,7 @@ _Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol ### TypeScript SDK (implemented) -Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today. +Inspector depends on the **v2 SDK packages** — `@modelcontextprotocol/client` / `core` / `server` / `server-legacy` at **2.0.0**, declared in the root `package.json` only. Standard OAuth and EMA both build on `@modelcontextprotocol/client`; every module in the table below imports it. The v1 `@modelcontextprotocol/sdk` is **not** a dependency of this repo and must not become one — it appears in the lock files solely as a `peer` pulled in by `ext-apps`. | Concern | Package / module | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | From 27cd42b479eecfe45e572ec3b9e48afd76a5b6d4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Wed, 12 Aug 2026 11:01:27 -0400 Subject: [PATCH 113/941] chore: 2.2.0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a2c7a55b..bb4866ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4650935b..4e0295ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.1.0", + "version": "2.2.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From cf834633ba99a569ae766083bde166646fc13ac6 Mon Sep 17 00:00:00 2001 From: olaservo <olahungerford@gmail.com> Date: Wed, 12 Aug 2026 08:56:24 -0700 Subject: [PATCH 114/941] fix: add eol=lf policy to .gitattributes so Windows checkouts stay clean Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index cf968721..056e3cb9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ +* text=auto eol=lf package-lock.json linguist-generated=true From 9f7895dcd976daca6bbe41efc1b381ec4e82b0c1 Mon Sep 17 00:00:00 2001 From: olaservo <olahungerford@gmail.com> Date: Wed, 12 Aug 2026 08:57:35 -0700 Subject: [PATCH 115/941] fix: invoke the registered SIGINT listener directly in run-method.test instead of emitting a process-wide signal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- clients/cli/__tests__/run-method.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/clients/cli/__tests__/run-method.test.ts b/clients/cli/__tests__/run-method.test.ts index 783ba60b..d8599223 100644 --- a/clients/cli/__tests__/run-method.test.ts +++ b/clients/cli/__tests__/run-method.test.ts @@ -258,18 +258,29 @@ describe("runMethod", () => { ); expect(stdout.trim().split("\n")).toHaveLength(2); + // Invoke only the SIGINT handler consumeMethodOutcome registers, rather + // than broadcasting `process.emit("SIGINT")` process-wide: `atomically` + // (loaded via core's store-io) pulls in `when-exit`, whose module-level + // `process.once("SIGINT")` handler re-raises the signal with + // `process.kill(process.pid, "SIGINT")` — on Windows an unconditional + // TerminateProcess that kills the vitest worker fork mid-run (#1941). + const listenersBefore = new Set(process.listeners("SIGINT")); const streamPromise = consumeMethodOutcome( { kind: "stream", label: "t", start: (write) => { write({ hi: true }); - queueMicrotask(() => process.emit("SIGINT")); return () => {}; }, }, {}, ); + const added = process + .listeners("SIGINT") + .filter((listener) => !listenersBefore.has(listener)); + expect(added).toHaveLength(1); + for (const listener of added) listener("SIGINT"); await streamPromise; expect(stdout).toContain('"hi":true'); } finally { From 9c6e4aeb4e827937abbef489ac5cd154a8919d6a Mon Sep 17 00:00:00 2001 From: olaservo <olahungerford@gmail.com> Date: Wed, 12 Aug 2026 08:57:44 -0700 Subject: [PATCH 116/941] fix: settle ink input-handler re-subscription after frame waits in TUI App tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- clients/tui/__tests__/App.test.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index d70b9c27..9025b52e 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -552,9 +552,29 @@ async function press(r: RenderResult, keys: string[]) { */ const POLL_TRIES = 100; +/** + * One check-phase turn, queued BEHIND React's already-scheduled passive-effect + * flush. A frame observed by a poll predicate is written during React's + * COMMIT, but ink re-arms its useInput listeners in the passive-effect flush + * React schedules (via setImmediate in Node) during that same commit. Node's + * event loop runs the timers phase before the check phase, so a 25ms poll + * tick can observe the new frame and let the test write the next keypress + * BEFORE that flush has run — the key is then dispatched to the previous + * commit's stale useInput closures (where e.g. pendingStepUp is still null) + * and silently swallowed (#1942). Yielding one setImmediate turn after the + * predicate passes sequences the next stdin write after the flush (FIFO + * within the check queue), so "frame visible" once again implies "input + * handlers armed". + */ +const settleInputHandlers = () => + new Promise((resolve) => setImmediate(resolve)); + async function waitUntil(predicate: () => boolean, tries = POLL_TRIES) { for (let i = 0; i < tries; i++) { - if (predicate()) return; + if (predicate()) { + await settleInputHandlers(); + return; + } await tick(); } } From e3a026802bc8037afe025ecd8f42ce54c811f26d Mon Sep 17 00:00:00 2001 From: olaservo <olahungerford@gmail.com> Date: Wed, 12 Aug 2026 09:04:31 -0700 Subject: [PATCH 117/941] fix: resolve node bins via package.json instead of spawning npx .cmd shims so the verify/smoke scripts run on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows npx/npm are .cmd shims a shell-free execFileSync/spawnSync cannot start (ENOENT since the CVE-2024-27980 hardening), so verify:typecheck-coverage died at validate's second step — doubly silently, echoing "(no diagnostic captured)" per project and then reporting all 918 tracked files as getting no tsc pass — and verify:build-gate, the smoke test-server bootstraps, and pack:verify's npm pack failed the same way. Add scripts/lib/resolve-node-bin.mjs: resolves the JS entry behind a package's bin from <pkg>/package.json (deep resolution is blocked by Vite 8's exports map) and spawns it via process.execPath — the same walk npx --no-install did, cross-platform and shell-free. Switch the six npx call sites to it; the npm pack call gets the existing shell-on-win32 idiom instead (npm has no in-tree package to resolve). An unresolvable tsc is now a hard "cannot measure" error with actionable stderr, pinned by a new main() regression test. Closes #1939 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- scripts/lib/resolve-node-bin.mjs | 41 +++++++ scripts/lib/resolve-node-bin.test.mjs | 68 ++++++++++++ scripts/pack-and-verify.mjs | 4 +- scripts/smoke-cli.mjs | 17 ++- scripts/smoke-tui.mjs | 17 ++- scripts/smoke-web-app.mjs | 17 ++- scripts/verify-build-gate.mjs | 34 ++++-- .../verify-typecheck-coverage.main.test.mjs | 105 ++++++++++++++++++ scripts/verify-typecheck-coverage.mjs | 33 +++++- 9 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 scripts/lib/resolve-node-bin.mjs create mode 100644 scripts/lib/resolve-node-bin.test.mjs create mode 100644 scripts/verify-typecheck-coverage.main.test.mjs diff --git a/scripts/lib/resolve-node-bin.mjs b/scripts/lib/resolve-node-bin.mjs new file mode 100644 index 00000000..528611c8 --- /dev/null +++ b/scripts/lib/resolve-node-bin.mjs @@ -0,0 +1,41 @@ +// Shared resolver for spawning a package's CLI cross-platform (#1939). +// +// On Windows, `npx`/`npm` are `.cmd` shims, not executables — a shell-free +// `execFileSync`/`spawnSync` cannot start one (Node refuses `.cmd`/`.bat` +// spawns without `shell: true` since the CVE-2024-27980 hardening) and throws +// `ENOENT`. GitHub CI runs Linux, so the gate stayed green there while being +// unrunnable for any Windows contributor. Instead of shelling through `npx`, +// resolve the JS entry behind the package's bin and run it with +// `process.execPath`: cross-platform, shell-free (no quoting hazards), faster +// (no npx resolution), and pinned to the locally installed package exactly as +// `npx --no-install` was. + +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +/** + * Absolute path of the JS entry behind a package's bin (e.g. typescript's + * `tsc`, vite's `vite`), resolved from `fromDir` up the node_modules tree — + * the same walk `npx --no-install` does, minus the `.cmd` shim a shell-free + * spawn can't start on Windows. Resolves `<pkg>/package.json` and reads its + * `bin` field (what npx itself does) rather than resolving the bin path + * directly, because an `exports` map blocks deep resolution — Vite 8 doesn't + * export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")` throws + * `ERR_PACKAGE_PATH_NOT_EXPORTED` (`./package.json` is always exported). + * + * Throws if the package isn't installed from `fromDir` or declares no such + * bin — the caller decides whether that's a hard "cannot measure" error or a + * fallback. + */ +export function resolveNodeBin(pkg, binName, fromDir) { + const pkgPath = createRequire(path.join(fromDir, "package.json")).resolve( + `${pkg}/package.json`, + ); + const bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin; + // A string-form `bin` names a single command (the package's own name). + const rel = typeof bin === "string" ? bin : bin?.[binName]; + if (typeof rel !== "string") + throw new Error(`${pkg} declares no "${binName}" bin in its package.json`); + return path.join(path.dirname(pkgPath), rel); +} diff --git a/scripts/lib/resolve-node-bin.test.mjs b/scripts/lib/resolve-node-bin.test.mjs new file mode 100644 index 00000000..cac917e2 --- /dev/null +++ b/scripts/lib/resolve-node-bin.test.mjs @@ -0,0 +1,68 @@ +// Tests for `resolve-node-bin.mjs` (#1939) — the shared resolver that replaces +// shelling through `npx`, which is a `.cmd` shim on Windows that a shell-free +// `execFileSync`/`spawnSync` cannot start (ENOENT). Resolution is exercised +// against the packages the callers actually spawn (typescript, vite, prettier), +// installed by the repo's own `npm install`, so the contract is pinned against +// the real `bin`/`exports` shapes rather than fixtures that can drift. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveNodeBin } from "./resolve-node-bin.mjs"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +test("resolves typescript's tsc (object-form bin) to an existing JS entry", () => { + const entry = resolveNodeBin("typescript", "tsc", repoRoot); + assert.ok(path.isAbsolute(entry), entry); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); + assert.match(entry.split(path.sep).join("/"), /\/typescript\/.*tsc/); +}); + +test("resolves from a client dir, walking node_modules up like `npx --no-install`", () => { + const entry = resolveNodeBin( + "typescript", + "tsc", + path.join(repoRoot, "clients", "cli"), + ); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); +}); + +test("resolves vite's bin despite Vite 8's exports map (no deep bin export)", () => { + // `require.resolve("vite/bin/vite.js")` throws ERR_PACKAGE_PATH_NOT_EXPORTED + // under Vite 8 — the reason the helper goes through `<pkg>/package.json`. + const entry = resolveNodeBin( + "vite", + "vite", + path.join(repoRoot, "clients", "web"), + ); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); + assert.match(entry.split(path.sep).join("/"), /\/vite\/.*vite\.js$/); +}); + +test("resolves a string-form bin (prettier), ignoring the binName", () => { + const entry = resolveNodeBin("prettier", "prettier", repoRoot); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); + assert.match(entry.split(path.sep).join("/"), /\/prettier\//); +}); + +test("throws when the package is not installed from fromDir", () => { + assert.throws( + () => resolveNodeBin("definitely-not-installed-anywhere", "x", repoRoot), + /definitely-not-installed-anywhere/, + ); +}); + +test("throws when the package declares no such bin", () => { + // typescript's bin map has `tsc`/`tsserver`, not `vite`. + assert.throws( + () => resolveNodeBin("typescript", "vite", repoRoot), + /typescript declares no "vite" bin/, + ); +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index ff1dd310..2cd4494d 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -137,7 +137,9 @@ step("packing the publishable tarball (npm pack)..."); const pack = spawnSync( "npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", tmpdir()], - { cwd: repoRoot, encoding: "utf8" }, + // npm is npm.cmd on Windows, which needs a shell to resolve (#1939) — the + // same idiom as runInherit/runBin below. + { cwd: repoRoot, encoding: "utf8", shell: process.platform === "win32" }, ); if (pack.status !== 0) { fail(`\`npm pack\` failed:\n${pack.stderr || pack.stdout}`); diff --git a/scripts/smoke-cli.mjs b/scripts/smoke-cli.mjs index ef93bdfd..80660208 100644 --- a/scripts/smoke-cli.mjs +++ b/scripts/smoke-cli.mjs @@ -44,6 +44,7 @@ import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const launcher = join(repoRoot, "clients", "launcher", "build", "index.js"); @@ -69,10 +70,18 @@ function fail(message) { function ensureTestServer() { if (existsSync(testServer) && existsSync(httpTestServerModule)) return; console.log("smoke:cli — building test-servers (missing build output)..."); - const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], { - cwd: repoRoot, - stdio: "inherit", - }); + // The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on + // Windows that a shell-free spawnSync can't start (ENOENT — #1939). + const r = spawnSync( + process.execPath, + [ + resolveNodeBin("typescript", "tsc", repoRoot), + "-p", + "test-servers", + "--noCheck", + ], + { cwd: repoRoot, stdio: "inherit" }, + ); if ( r.status !== 0 || !existsSync(testServer) || diff --git a/scripts/smoke-tui.mjs b/scripts/smoke-tui.mjs index d6244cd3..bf2a8e2d 100644 --- a/scripts/smoke-tui.mjs +++ b/scripts/smoke-tui.mjs @@ -24,6 +24,7 @@ import { mkdtempSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { removeSafe } from "./lib/child-cleanup.mjs"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const launcher = join(repoRoot, "clients", "launcher", "build", "index.js"); @@ -44,10 +45,18 @@ function fail(message) { function ensureTestServer() { if (existsSync(testServer)) return; console.log("smoke:tui — building test-servers (missing build output)..."); - const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], { - cwd: repoRoot, - stdio: "inherit", - }); + // The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on + // Windows that a shell-free spawnSync can't start (ENOENT — #1939). + const r = spawnSync( + process.execPath, + [ + resolveNodeBin("typescript", "tsc", repoRoot), + "-p", + "test-servers", + "--noCheck", + ], + { cwd: repoRoot, stdio: "inherit" }, + ); if (r.status !== 0 || !existsSync(testServer)) { fail( "could not build the stdio test server (test-servers/build/test-server-stdio.js). " + diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index c5f2f8a6..4dafa18f 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -53,6 +53,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { join, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const requireFromWeb = createRequire( @@ -140,10 +141,18 @@ function ensureTestServer() { console.log( "smoke:web:app — building test-servers (missing build output)...", ); - const r = spawnSync("npx", ["tsc", "-p", "test-servers", "--noCheck"], { - cwd: repoRoot, - stdio: "inherit", - }); + // The root-installed tsc, run via this Node — `npx` is a `.cmd` shim on + // Windows that a shell-free spawnSync can't start (ENOENT — #1939). + const r = spawnSync( + process.execPath, + [ + resolveNodeBin("typescript", "tsc", repoRoot), + "-p", + "test-servers", + "--noCheck", + ], + { cwd: repoRoot, stdio: "inherit" }, + ); if (r.status !== 0 || !existsSync(composableServer)) { throw new Error( "could not build the test servers (test-servers/build/server-composable.js). " + diff --git a/scripts/verify-build-gate.mjs b/scripts/verify-build-gate.mjs index 007e556e..350d62f9 100644 --- a/scripts/verify-build-gate.mjs +++ b/scripts/verify-build-gate.mjs @@ -32,6 +32,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -82,6 +83,20 @@ function escapeRegExp(literal) { return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +// The repo-pinned Vite's JS entry, resolved from clients/web — the old `npx +// --no-install` guarantee (never a registry fetch) — and spawned via this Node, +// since `npx` is a `.cmd` shim on Windows that a shell-free spawnSync can't +// start (ENOENT — #1939). Resolved up front, BEFORE the probe is injected, so a +// missing install fails actionably without ever touching src/main.tsx. +let viteEntry; +try { + viteEntry = resolveNodeBin("vite", "vite", webDir); +} catch (err) { + fail( + `cannot resolve \`vite\` from clients/web (${err.message}) — run \`npm install\` at the repo root first`, + ); +} + // Write the captured original to a backup and fail — the honest remedy when the // in-place restore can't be trusted, since it preserves any uncommitted edits // the developer had (unlike `git checkout --`). The backup goes in a fresh @@ -229,15 +244,14 @@ try { console.log( "verify:build-gate: running a real `vite build` with a node:fs probe (takes a minute)…", ); - // `--no-install` pins to the locally installed (repo-pinned) Vite: the whole - // point is proving the message-keyed gate fires against THIS Vite, so `npx` - // must never silently fetch a different version from the registry when - // clients/web/node_modules is missing/partial. A missing local bin then - // surfaces via the `result.error` check below. `timeout` bounds a hung build: - // spawnSync sets `result.error` (ETIMEDOUT) on timeout, so the same branch - // reports it — otherwise a hang would burn to the GitHub job's 360-min default - // with no output (this step captures rather than inherits stdio). - result = spawnSync("npx", ["--no-install", "vite", "build"], { + // `viteEntry` (resolved above) pins to the locally installed (repo-pinned) + // Vite: the whole point is proving the message-keyed gate fires against THIS + // Vite, so nothing may silently fetch a different version from the registry + // when clients/web/node_modules is missing/partial. `timeout` bounds a hung + // build: spawnSync sets `result.error` (ETIMEDOUT) on timeout, so that branch + // below reports it — otherwise a hang would burn to the GitHub job's 360-min + // default with no output (this step captures rather than inherits stdio). + result = spawnSync(process.execPath, [viteEntry, "build"], { cwd: webDir, encoding: "utf8", timeout: 10 * 60_000, @@ -266,7 +280,7 @@ if (afterRestore !== original) { ); } -// A spawn failure (e.g. `npx` missing) leaves `status` null with no output — +// A spawn failure (or the timeout above) leaves `status` null with no output — // surface it as itself rather than falling through to the "not via the gate" // diagnosis, which would send someone chasing a build regression that isn't real. if (result.error) { diff --git a/scripts/verify-typecheck-coverage.main.test.mjs b/scripts/verify-typecheck-coverage.main.test.mjs new file mode 100644 index 00000000..e7ac5ac8 --- /dev/null +++ b/scripts/verify-typecheck-coverage.main.test.mjs @@ -0,0 +1,105 @@ +// Regression test for #1939's "doubly silent" failure mode: when the tsc entry +// cannot be resolved (on Windows the old `execFileSync("npx", …)` threw ENOENT; +// today, a missing install), the guard must hard-fail with an actionable +// "cannot measure" error — NOT swallow it, echo "(no diagnostic captured)" per +// project, and then report every tracked source file in the repo as getting no +// tsc pass, which is what shipped before and sent Windows contributors chasing +// a 900-file coverage regression that wasn't real. +// +// The fixture is a throwaway repo with one enrolled client whose `typecheck` +// names a project, but with NO node_modules anywhere up the temp tree — so the +// guard reaches the tsc-entry resolution and it fails. Mirrors the +// `verify-format-coverage.main.test.mjs` / `verify-dep-lockstep.main.test.mjs` +// pattern. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + cpSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); + +test("unresolvable tsc is a hard 'cannot measure' error, not an empty file set", () => { + // realpath'd because the script only executes when `import.meta.url` matches + // `process.argv[1]`, and macOS `tmpdir()` is a symlink — see the note in + // `verify-dep-lockstep.main.test.mjs`. + const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "typecheck-cov-"))); + try { + // Root manifest: the full guard cycle is wired so phase 1 gets as far as + // measuring the client (an unwired client is `continue`d past, and the + // resolution would never be reached). + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: "fixture", + scripts: { + validate: + "npm run verify:format-coverage && npm run verify:typecheck-coverage && npm run verify:dep-lockstep && npm run test:scripts && npm --prefix clients/cli run validate", + "verify:format-coverage": "node scripts/verify-format-coverage.mjs", + "verify:typecheck-coverage": + "node scripts/verify-typecheck-coverage.mjs", + "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", + "test:scripts": 'node --test "scripts/**/*.test.mjs"', + }, + }), + ); + // One enrolled client with a `typecheck` reachable from `validate`, a + // project for it to name, and a tracked source file — so if the hard error + // ever regresses to the old behavior, the "get no `tsc` pass" report the + // second assertion forbids would actually have a file to list. + mkdirSync(path.join(dir, "clients", "cli", "src"), { recursive: true }); + writeFileSync( + path.join(dir, "clients", "cli", "package.json"), + JSON.stringify({ + name: "fixture-cli", + scripts: { + validate: "npm run typecheck", + typecheck: "tsc --noEmit -p tsconfig.json", + }, + }), + ); + writeFileSync( + path.join(dir, "clients", "cli", "tsconfig.json"), + JSON.stringify({ include: ["src"] }), + ); + writeFileSync( + path.join(dir, "clients", "cli", "src", "index.ts"), + "export const x = 1;\n", + ); + mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); + for (const rel of [ + "verify-typecheck-coverage.mjs", + path.join("lib", "npm-scripts.mjs"), + path.join("lib", "resolve-node-bin.mjs"), + ]) + cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); + execFileSync("git", ["init", "-q"], { cwd: dir }); + execFileSync("git", ["add", "-A"], { cwd: dir }); + + const r = spawnSync( + process.execPath, + [path.join(dir, "scripts", "verify-typecheck-coverage.mjs")], + { cwd: dir, encoding: "utf8" }, + ); + const out = `${r.stdout}${r.stderr}`; + assert.equal(r.status, 1, out); + assert.match(out, /cannot resolve `typescript` from clients\/cli/, out); + assert.match(out, /npm install/, out); + // The pre-#1939 failure shape: per-project "(no diagnostic captured)" + // warnings followed by every tracked file reported uncovered. + assert.doesNotMatch(out, /get no `tsc` pass/, out); + assert.doesNotMatch(out, /no diagnostic captured/, out); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 20d6ab2c..95312313 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -47,6 +47,7 @@ import { rootRunsClientValidate, tokenize, } from "./lib/npm-scripts.mjs"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -456,8 +457,8 @@ export function typecheckProjects(scripts) { function projectDisablesChecking(clientDir, project) { try { const out = execFileSync( - "npx", - ["--no-install", "tsc", "-p", project, "--showConfig"], + process.execPath, + [tscEntry(clientDir), "-p", project, "--showConfig"], { cwd: path.join(repoRoot, clientDir), encoding: "utf8" }, ); return JSON.parse(out)?.compilerOptions?.noCheck === true; @@ -473,6 +474,30 @@ function projectDisablesChecking(clientDir, project) { * in the set but are harmless — the set is only ever queried with client-relative * paths. Cached: `resolveLeafProjects` and `projectFiles` both list a project. */ +// The tsc JS entry each client's projects are measured with, resolved from the +// client dir up the node_modules tree exactly as `npx --no-install tsc` walked +// — but spawnable shell-free on Windows, where `npx` is a `.cmd` shim that +// `execFileSync` can't start (ENOENT — #1939). A resolution failure is a hard +// "cannot measure" error rather than an empty file set: the old ENOENT was +// doubly silent, echoing "(no diagnostic captured)" per project and then +// reporting every tracked file in the repo as uncovered. +const tscEntryCache = new Map(); +function tscEntry(clientDir) { + const cached = tscEntryCache.get(clientDir); + if (cached) return cached; + let entry; + try { + entry = resolveNodeBin("typescript", "tsc", path.join(repoRoot, clientDir)); + } catch (err) { + console.error( + `verify:typecheck-coverage — cannot resolve \`typescript\` from ${clientDir} (${err.message}): this guard cannot measure anything. Run \`npm install\` at the repo root first.`, + ); + process.exit(1); + } + tscEntryCache.set(clientDir, entry); + return entry; +} + const rawFilesCache = new Map(); function rawProjectFiles(clientDir, project) { const key = `${clientDir}|${project}`; @@ -482,8 +507,8 @@ function rawProjectFiles(clientDir, project) { let stdout; try { stdout = execFileSync( - "npx", - ["--no-install", "tsc", "-p", project, "--listFilesOnly"], + process.execPath, + [tscEntry(clientDir), "-p", project, "--listFilesOnly"], { cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); } catch (err) { From f3b3a756cd92b9b1b0d4ca15e62a4c637cc16538 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:48:04 +0000 Subject: [PATCH 118/941] chore(deps): Bump the npm_and_yarn group across 4 directories with 6 updates Bumps the npm_and_yarn group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [hono](https://github.com/honojs/hono) | `4.12.25` | `4.13.1` | | [undici](https://github.com/nodejs/undici) | `8.5.0` | `8.9.0` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [fast-uri](https://github.com/fastify/fast-uri) | `3.1.0` | `3.1.5` | | [ip-address](https://github.com/beaugunderson/ip-address) | `10.1.0` | `10.5.0` | Bumps the npm_and_yarn group with 2 updates in the /clients/cli directory: [undici](https://github.com/nodejs/undici) and [fast-uri](https://github.com/fastify/fast-uri). Bumps the npm_and_yarn group with 1 update in the /clients/tui directory: [fast-uri](https://github.com/fastify/fast-uri). Bumps the npm_and_yarn group with 1 update in the /clients/web directory: [hono](https://github.com/honojs/hono). Updates `hono` from 4.12.25 to 4.13.1 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.25...v4.13.1) Updates `undici` from 8.5.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.9.0) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `ip-address` from 10.1.0 to 10.5.0 - [Release notes](https://github.com/beaugunderson/ip-address/releases) - [Commits](https://github.com/beaugunderson/ip-address/compare/v10.1.0...v10.5.0) Updates `qs` from 6.15.1 to 6.15.3 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.1...v6.15.3) Updates `undici` from 8.5.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.9.0) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `hono` from 4.12.25 to 4.13.1 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.25...v4.13.1) Updates `fast-uri` from 3.1.0 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `undici` from 8.5.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.9.0) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `undici` from 8.5.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.9.0) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `fast-uri` from 3.1.2 to 3.1.5 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.5) Updates `hono` from 4.12.33 to 4.13.1 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.25...v4.13.1) Updates `hono` from 4.12.33 to 4.13.1 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.25...v4.13.1) --- updated-dependencies: - dependency-name: hono dependency-version: 4.13.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: ip-address dependency-version: 10.5.0 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.15.3 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.13.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.13.1 dependency-type: direct:production dependency-group: npm_and_yarn - dependency-name: hono dependency-version: 4.13.1 dependency-type: direct:production dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] <support@github.com> --- clients/cli/package-lock.json | 14 ++-- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 6 +- clients/web/package-lock.json | 8 +-- clients/web/package.json | 2 +- package-lock.json | 122 +++++++++++++++++++++------------- 6 files changed, 93 insertions(+), 61 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 0e30913b..f81d37e6 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -14,7 +14,7 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", + "undici": "^8.9.0", "zod": "^4.4.3" }, "bin": { @@ -2800,9 +2800,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -4441,9 +4441,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/clients/cli/package.json b/clients/cli/package.json index 54f2dc5c..08fa3eb2 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -38,7 +38,7 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", + "undici": "^8.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 42812dbb..062b7e2b 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -3247,9 +3247,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 6bbfc0e0..26f91ccf 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -20,7 +20,7 @@ "atomically": "^2.1.1", "chokidar": "^4.0.3", "commander": "^13.1.0", - "hono": "^4.12.18", + "hono": "^4.13.1", "open": "^10.2.0", "papaparse": "^5.5.4", "pino": "^9.14.0", @@ -5873,9 +5873,9 @@ } }, "node_modules/hono": { - "version": "4.12.33", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", - "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/clients/web/package.json b/clients/web/package.json index cc0de377..00de16b1 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -46,7 +46,7 @@ "atomically": "^2.1.1", "chokidar": "^4.0.3", "commander": "^13.1.0", - "hono": "^4.12.18", + "hono": "^4.13.1", "open": "^10.2.0", "papaparse": "^5.5.4", "pino": "^9.14.0", diff --git a/package-lock.json b/package-lock.json index bb4866ac..ea4b6c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1399,20 +1399,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1422,6 +1422,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -1807,9 +1820,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2110,12 +2123,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2148,9 +2162,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -2393,9 +2407,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2405,9 +2419,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2537,9 +2551,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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" @@ -3463,12 +3477,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3746,14 +3761,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4040,17 +4055,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -4092,9 +4124,9 @@ } }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" From adde9ccf948a3d23fff93270be6c53188f4d5d0b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 12:13:06 -0400 Subject: [PATCH 119/941] fix: bind loopback by address and pin the sandbox port so containers work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defaults that each break the Inspector inside a container. #1951 — the web UI never loads in a VS Code dev container. `HOST` defaulted to the NAME `localhost`, and `server.listen(port, host)` resolves a name through `dns.lookup` and binds the SINGLE address it gets back. Since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose /etc/hosts maps localhost to both 127.0.0.1 and ::1 — that is `::1`. The server bound IPv6 loopback only and refused every IPv4 client, including the dev container port forwarder. It presents as an endless spinner rather than a connection error because the forwarder accepts on the host side before failing to connect inward. Default to the ADDRESS 127.0.0.1 instead; an explicit HOST=localhost is still honored as typed. #2008 — the Apps tab is unreachable in any container. The MCP Apps sandbox listened on an OS-assigned port, so it changed every run and could never be named in a devcontainer `forwardPorts`, a `docker run -p`, or a tunnel. Default it to a fixed 6275, mirroring CLIENT_PORT's fixed 6274, and retry once on an OS-assigned port (loudly) if it is taken — the collision a dynamic default never had. Verified in the reporter's exact image (mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie): both listeners come up on 127.0.0.1 and answer 200 on 6274 and 6275/sandbox. Also EXPOSE 6275 and set MCP_SANDBOX_PORT in the image, and document both defaults in the web README, the migration guide, and the root README's Docker section. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 5 +- Dockerfile | 6 +- README.md | 9 ++ clients/web/README.md | 6 +- clients/web/server/resolve-bind-host.ts | 43 ++++-- clients/web/server/sandbox-controller.ts | 61 ++++++++- .../server/resolve-bind-host.test.ts | 16 ++- .../server/sandbox-controller.test.ts | 127 +++++++++++++++--- .../server/web-server-config.test.ts | 21 +-- docs/v1-to-v2-migration.md | 8 +- 10 files changed, 248 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21061009..4123ccad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ v2/main/ │ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server), │ │ │ # inject-auth-token.ts (embeds the API token into served index.html), │ │ │ # vite-base-config.ts (shared optimizeDeps exclusions), -│ │ │ # resolve-bind-host.ts (bind-host POLICY: refuses an +│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to +│ │ │ # 127.0.0.1 — an ADDRESS, never the name `localhost`, which +│ │ │ # listen() resolves to a SINGLE family (::1 on glibc Linux), +│ │ │ # refusing every IPv4 client — #1951; and refuses an │ │ │ # all-interfaces HOST unless DANGEROUSLY_BIND_ALL_INTERFACES; │ │ │ # the all-interfaces DETECTION is core/node/hostUrl.isAllInterfacesHost. │ │ │ # Used by both bind points — web-server-config.ts + vite.config.ts — #1795), diff --git a/Dockerfile b/Dockerfile index f87a32ce..24455398 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,12 @@ RUN npm install -g /tmp/inspector.tgz && rm /tmp/inspector.tgz ENV HOST=0.0.0.0 \ DANGEROUSLY_BIND_ALL_INTERFACES=true \ CLIENT_PORT=6274 \ + MCP_SANDBOX_PORT=6275 \ MCP_AUTO_OPEN_ENABLED=false -EXPOSE 6274 +# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly. +# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is +# optional — see the Docker section of the root README. +EXPOSE 6274 6275 # Run as the non-root `node` user the base image ships. The inspector resolves # its runtime-state dir (default catalog, OAuth token storage) from `HOME` diff --git a/README.md b/README.md index 55459ad0..35b1ce6d 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,15 @@ docker build -t mcp-inspector . docker run --rm -p 127.0.0.1:6274:6274 mcp-inspector ``` +**Using the Apps tab? Publish `6275` too.** The MCP Apps sandbox is a second listener the browser reaches directly, on `MCP_SANDBOX_PORT` (default `6275`). Nothing else needs it, so the single-port commands above are fine for ordinary inspection — but the Apps tab renders a blank widget without it: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ + ghcr.io/modelcontextprotocol/inspector +``` + +Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. + **Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one. **Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it: diff --git a/clients/web/README.md b/clients/web/README.md index a3facd92..01873854 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -162,7 +162,9 @@ The dev/prod backend guards every `/api/*` route with `x-mcp-remote-auth: Bearer ## Host binding & the origin allow-list -Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds `localhost` by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. +Both the prod backend (`server/web-server-config.ts`) and the dev Vite server (`vite.config.ts`) resolve their bind host through one shared guard, `server/resolve-bind-host.ts`. It binds **`127.0.0.1`** by default and **refuses an all-interfaces host** (`0.0.0.0`, `::`, empty, or any equivalent spelling — `0`, `0x0`, `0.0`, `::0`, `::ffff:0.0.0.0`, … are all folded to the wildcard and refused) — which would expose the process-spawning backend to the whole network, the exposure DNS-rebinding attacks target — unless `DANGEROUSLY_BIND_ALL_INTERFACES=true` is set. The Docker image sets that flag (a container must bind `0.0.0.0` to be reachable through `-p`); a bare `HOST=0.0.0.0` anywhere else exits with an actionable error. + +**The default is the IPv4 loopback _address_, deliberately not the name `localhost`** (#1951). `server.listen(port, host)` resolves a name through `dns.lookup` and binds the **single** address it gets back; since Node 17 the default result order is `verbatim`, so on a glibc Linux host — whose `/etc/hosts` maps `localhost` to both `127.0.0.1` and `::1` — that first address is `::1`. `HOST=localhost` there binds IPv6 loopback **only** and refuses every IPv4 client. On a desktop this is invisible (the browser resolves `localhost` the same way and lands on `::1` too); it breaks wherever the connection is made by something that pins `127.0.0.1` — a VS Code dev container's port forwarder, `ssh -L 6274:127.0.0.1:6274`, a container healthcheck. Binding the address takes the resolver out of the decision. Browsing to `http://localhost:PORT` still works, since browsers fall back across address families and the loopback allow-list covers all three forms. An explicit `HOST=localhost` is still honored as typed — the single-family bind is then your choice. The backend's `/api/*` routes also enforce an **origin allow-list** (`allowedOrigins`) as DNS-rebinding protection. When left to default on a loopback host, it expands to all three interchangeable loopback origin forms for the port — `http://localhost:PORT`, `http://127.0.0.1:PORT`, and `http://[::1]:PORT` — because `localhost` resolves to either IPv4 or IPv6 loopback and Node/Vite may bind the IPv6 form, so the browser can legitimately arrive at `http://[::1]:PORT`. Set `ALLOWED_ORIGINS` (comma-separated) to override; entries are canonicalized (`new URL(o).origin`), so a trailing slash / uppercase host / explicit `:80` still match. **Each entry must include the scheme** — `http://localhost:6274`, not `localhost:6274` (a scheme-less value is dropped with a warning). `ALLOWED_ORIGINS` **replaces** the default list (it does not merge), so **list every origin you'll browse from, including the loopback forms** you still want (`http://localhost:PORT`, `http://127.0.0.1:PORT`, `http://[::1]:PORT`) — otherwise local access stops working. A blank `ALLOWED_ORIGINS` does **not** disable the check — it falls back to the default (fail closed); there is no env knob to turn origin validation off. @@ -176,7 +178,7 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`. -**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port (`MCP_SANDBOX_PORT`, dynamic by default). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — pin it with `MCP_SANDBOX_PORT` and expose/forward it too (the Docker image `EXPOSE`s only `6274`). (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. +**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today. In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard. diff --git a/clients/web/server/resolve-bind-host.ts b/clients/web/server/resolve-bind-host.ts index a6d6fda1..f18945ce 100644 --- a/clients/web/server/resolve-bind-host.ts +++ b/clients/web/server/resolve-bind-host.ts @@ -26,21 +26,46 @@ function isEnabled(value: string | undefined): boolean { return v === "true" || v === "1"; } +/** + * The default bind host: the IPv4 loopback **address**, deliberately not the + * name `localhost`. + * + * `server.listen(port, host)` resolves a name through `dns.lookup` and binds the + * **single** address it gets back. Since Node 17 the default result order is + * `verbatim`, so Node takes whatever the resolver returns first rather than + * preferring IPv4 — and on a glibc Linux host with the stock `/etc/hosts` + * (`127.0.0.1 localhost` *and* `::1 localhost`) that is `::1`. `HOST=localhost` + * therefore binds IPv6 loopback **only**, and every IPv4 client is refused. + * + * That is invisible on a desktop, where the browser resolves `localhost` the + * same way and lands on `::1` too. It breaks wherever the connection is made by + * something that pins IPv4 `127.0.0.1` instead — a VS Code dev container's port + * forwarder (#1951), an `ssh -L …:127.0.0.1:…` tunnel, a container healthcheck. + * Binding the address directly removes the resolver from the decision, and the + * loopback origin allow-list already covers `localhost`, `127.0.0.1`, and + * `[::1]` alike, so browsing to `http://localhost:PORT` still works (browsers + * fall back across address families; a Node `listen` does not). + */ +export const DEFAULT_BIND_HOST = "127.0.0.1"; + /** * Resolve the bind hostname from `env` (default `process.env`), defaulting to - * `localhost`. Refuses an all-interfaces host (`0.0.0.0` / `::` / empty / their - * legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is explicitly - * enabled — the published Docker image sets it, since a container must bind - * `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) rather than - * silently binding wide open. The returned value is trimmed and de-bracketed - * (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so detection, - * `listen()`, and the origin list all consume the same value; `formatHostForUrl` - * re-adds the brackets wherever a URL is built. + * {@link DEFAULT_BIND_HOST}. Refuses an all-interfaces host (`0.0.0.0` / `::` / + * empty / their legacy spellings) unless {@link BIND_ALL_INTERFACES_ENV} is + * explicitly enabled — the published Docker image sets it, since a container + * must bind `0.0.0.0` to be reachable through `-p`. Throws (fail fast, loudly) + * rather than silently binding wide open. The returned value is trimmed and + * de-bracketed (an IPv6 literal is returned bare, e.g. `HOST=[::1]` → `::1`) so + * detection, `listen()`, and the origin list all consume the same value; + * `formatHostForUrl` re-adds the brackets wherever a URL is built. + * + * An explicit `HOST=localhost` is honored as typed — the single-family bind + * described on {@link DEFAULT_BIND_HOST} is then the caller's choice, not ours. */ export function resolveBindHostname( env: NodeJS.ProcessEnv = process.env, ): string { - const host = (env.HOST ?? "localhost").trim(); + const host = (env.HOST ?? DEFAULT_BIND_HOST).trim(); if (isAllInterfacesHost(host) && !isEnabled(env[BIND_ALL_INTERFACES_ENV])) { // Show the resolved address when it differs from the typed spelling — the // guard now catches forms the resolver folds to the wildcard (a fullwidth diff --git a/clients/web/server/sandbox-controller.ts b/clients/web/server/sandbox-controller.ts index e4b0d53e..50d4c440 100644 --- a/clients/web/server/sandbox-controller.ts +++ b/clients/web/server/sandbox-controller.ts @@ -11,13 +11,14 @@ import { canonicalUrlHost, isAllInterfacesHost, } from "../../../core/node/hostUrl.ts"; +import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export interface SandboxControllerOptions { /** Port to bind (0 = dynamic). */ port: number; - /** Host to bind (default localhost). */ + /** Host to bind (default {@link DEFAULT_BIND_HOST}). */ host?: string; /** * The backend's origin allow-list (the embedder origins). Used to build the @@ -95,10 +96,28 @@ function parseListenPort(raw: string | undefined): number | undefined { } /** - * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → 0 (dynamic). - * An invalid value falls through rather than crashing the boot; a set-but-invalid - * MCP_SANDBOX_PORT (the dedicated knob) is warned so the fall-through isn't - * silent — matching the warn-and-drop precedent for ALLOWED_ORIGINS. + * The default sandbox listen port — **fixed**, deliberately not `0`. + * + * A `0` default lets the OS assign, which is fine on a desktop (the browser + * learns the port from `/api/config`) but unusable anywhere the port has to be + * declared *before* the process starts: a dev container's `forwardPorts`, a + * `docker run -p`, a k8s service, an SSH tunnel. The port changed on every run, + * so it could never be named in that config and the Apps tab was unreachable + * (#2008). `CLIENT_PORT` has always defaulted to a fixed `6274` for the same + * reason; this is its sandbox counterpart. + * + * `6275` sits next to the web port so the pair is easy to forward together. It + * is not a v1 carry-over — v2 has no proxy server on it. + */ +export const DEFAULT_SANDBOX_PORT = 6275; + +/** + * Resolve sandbox port from env: MCP_SANDBOX_PORT → SERVER_PORT → + * {@link DEFAULT_SANDBOX_PORT}. An invalid value falls through rather than + * crashing the boot; a set-but-invalid MCP_SANDBOX_PORT (the dedicated knob) is + * warned so the fall-through isn't silent — matching the warn-and-drop + * precedent for ALLOWED_ORIGINS. An explicit `0` is still honored as "let the + * OS assign" ({@link parseListenPort} accepts it). */ export function resolveSandboxPort(): number { const fromSandbox = parseListenPort(process.env.MCP_SANDBOX_PORT); @@ -107,13 +126,21 @@ export function resolveSandboxPort(): number { `Ignoring invalid MCP_SANDBOX_PORT="${process.env.MCP_SANDBOX_PORT}" (need an integer 0–65535); falling back.`, ); } - return fromSandbox ?? parseListenPort(process.env.SERVER_PORT) ?? 0; + return ( + fromSandbox ?? + parseListenPort(process.env.SERVER_PORT) ?? + DEFAULT_SANDBOX_PORT + ); } export function createSandboxController( options: SandboxControllerOptions, ): SandboxController { - const { port, host = "localhost", allowedOrigins } = options; + // Defaulted to the same address the web server binds, never the name + // `localhost` — a name resolves to one address family and would reintroduce + // the #1951 split (web on IPv4, sandbox on IPv6) for any future call site + // that omits `host`. Both call sites pass `config.sandboxHost` today. + const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options; let server: Server | null = null; let sandboxUrl: string | null = null; @@ -177,8 +204,28 @@ export function createSandboxController( }); res.end(sandboxHtml); }); + // A taken port used to mean "no sandbox at all", which was tolerable + // only because the default was dynamic and therefore never collided. + // Now that it's fixed (see DEFAULT_SANDBOX_PORT), a second Inspector — + // or anything else already on 6275 — would take the Apps tab down, so + // retry once on an OS-assigned port. The retry is announced loudly: + // whoever pinned MCP_SANDBOX_PORT to forward it needs to know the pin + // didn't take, and a silently-moved port is exactly the failure this + // default was introduced to fix. + let retriedDynamic = false; server.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "EADDRINUSE") { + if (!retriedDynamic && port !== 0) { + retriedDynamic = true; + console.warn( + `Sandbox: port ${port} in use; falling back to an OS-assigned port. ` + + `The MCP Apps tab will work locally, but the sandbox is no longer ` + + `on a predictable port — set MCP_SANDBOX_PORT to a free one if you ` + + `need to forward it (container / SSH tunnel / reverse proxy).`, + ); + server!.listen(0, host); + return; + } console.error( `Sandbox: port ${port || "dynamic"} in use. MCP Apps tab may not work.`, ); diff --git a/clients/web/src/test/integration/server/resolve-bind-host.test.ts b/clients/web/src/test/integration/server/resolve-bind-host.test.ts index c6de0ada..691230a1 100644 --- a/clients/web/src/test/integration/server/resolve-bind-host.test.ts +++ b/clients/web/src/test/integration/server/resolve-bind-host.test.ts @@ -1,12 +1,24 @@ import { describe, it, expect } from "vitest"; import { BIND_ALL_INTERFACES_ENV, + DEFAULT_BIND_HOST, resolveBindHostname, } from "../../../../server/resolve-bind-host.js"; describe("resolveBindHostname", () => { - it("defaults to localhost when HOST is unset", () => { - expect(resolveBindHostname({})).toBe("localhost"); + // Regression guard for #1951. The default must be an *address*, never the + // name `localhost`: `listen()` resolves a name and binds the single address + // the resolver returns first, which on glibc Linux is `::1` — leaving every + // IPv4 client (a dev container's port forwarder, an `ssh -L` to 127.0.0.1, a + // container healthcheck) refused. See DEFAULT_BIND_HOST. + it("defaults to the IPv4 loopback address, not the name localhost", () => { + expect(resolveBindHostname({})).toBe("127.0.0.1"); + expect(resolveBindHostname({})).toBe(DEFAULT_BIND_HOST); + expect(resolveBindHostname({})).not.toBe("localhost"); + }); + + it("still honors an explicit HOST=localhost as typed", () => { + expect(resolveBindHostname({ HOST: "localhost" })).toBe("localhost"); }); it("returns a loopback HOST unchanged", () => { diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index 5db8d1b3..a1f73f7e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; import { createSandboxController, + DEFAULT_SANDBOX_PORT, resolveSandboxPort, sandboxFrameAncestors, } from "../../../../server/sandbox-controller.js"; @@ -75,7 +76,17 @@ describe("resolveSandboxPort", () => { else process.env.SERVER_PORT = envSnapshot.server; }); - it("returns 0 when no env vars are set", () => { + // Regression guard for #2008: the default must be a FIXED port. An + // OS-assigned one changes every run, so it can never be named in a dev + // container's `forwardPorts`, a `docker run -p`, or an SSH tunnel — leaving + // the Apps tab unreachable in every such environment. + it("returns a fixed default port when no env vars are set", () => { + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); + expect(resolveSandboxPort()).not.toBe(0); + }); + + it("still honors an explicit MCP_SANDBOX_PORT=0 as OS-assigned", () => { + process.env.MCP_SANDBOX_PORT = "0"; expect(resolveSandboxPort()).toBe(0); }); @@ -102,19 +113,19 @@ describe("resolveSandboxPort", () => { expect(resolveSandboxPort()).toBe(9100); }); - it("returns 0 when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { + it("falls back to the default when SERVER_PORT is non-numeric and no MCP_SANDBOX_PORT", () => { process.env.SERVER_PORT = "not-a-port"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores empty-string SERVER_PORT", () => { process.env.SERVER_PORT = ""; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("ignores negative values", () => { process.env.MCP_SANDBOX_PORT = "-1"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); it("rejects a partial-parse value (6274abc) rather than binding 6274", () => { @@ -125,7 +136,7 @@ describe("resolveSandboxPort", () => { it("rejects an out-of-range value (70000) so it can't crash listen", () => { process.env.MCP_SANDBOX_PORT = "70000"; - expect(resolveSandboxPort()).toBe(0); + expect(resolveSandboxPort()).toBe(DEFAULT_SANDBOX_PORT); }); }); @@ -135,7 +146,8 @@ describe("createSandboxController", () => { try { const { url, port } = await controller.start(); expect(port).toBeGreaterThan(0); - expect(url).toBe(`http://localhost:${port}/sandbox`); + // Default host is now the IPv4 loopback address, not the name (#1951). + expect(url).toBe(`http://127.0.0.1:${port}/sandbox`); expect(controller.getUrl()).toBe(url); const res = await fetch(url); @@ -312,11 +324,11 @@ describe("createSandboxController", () => { } }); - it("resolves with empty values when listen fails (EADDRINUSE)", async () => { - // Bind a placeholder HTTP server to claim a port, then point a sandbox - // controller at the same port to force EADDRINUSE. The Vite plugin awaits - // start() in configureServer; if start() ever stops resolving, the entire - // dev backend hangs. This test pins down the resolve-on-error contract. + /** Claim a port so a subsequent listen on it fails with EADDRINUSE. */ + async function claimPort(): Promise<{ + port: number; + release: () => Promise<void>; + }> { const blocker: Server = createServer(); await new Promise<void>((resolve) => blocker.listen(0, "127.0.0.1", () => resolve()), @@ -327,21 +339,96 @@ describe("createSandboxController", () => { ? addr.port : 0; expect(port).toBeGreaterThan(0); + return { + port, + release: () => + new Promise<void>((resolve) => blocker.close(() => resolve())), + }; + } + it("falls back to an OS-assigned port when the fixed port is taken", async () => { + // #2008 made the default port fixed, which introduced a collision the + // dynamic default never had: a second Inspector, or anything else already + // on 6275, would have taken the Apps tab down entirely. Retrying on a + // dynamic port keeps it working locally. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const controller = createSandboxController({ port, host: "127.0.0.1" }); + try { + const result = await controller.start(); + expect(result.port).toBeGreaterThan(0); + expect(result.port).not.toBe(port); + expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`); + expect(controller.getUrl()).toBe(result.url); + // The sandbox must actually be serving on the fallback port, not merely + // reporting one. + expect((await fetch(result.url)).status).toBe(200); + // Loud, not silent: a pinned-and-forwarded port that quietly moved is the + // very failure the fixed default exists to prevent. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining(`Sandbox: port ${port} in use`), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("MCP_SANDBOX_PORT"), + ); + } finally { + warnSpy.mockRestore(); + await controller.close(); + await release(); + } + }); + + it("resolves with empty values when even the dynamic retry fails", async () => { + // The Vite plugin awaits start() in configureServer; if start() ever stops + // resolving, the entire dev backend hangs. This pins the resolve-on-error + // contract for the case the retry can't rescue — an explicit `port: 0` that + // fails has nowhere left to fall back to, so it degrades as before. + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const controller = createSandboxController({ port: 0, host: "127.0.0.1" }); + try { + // Force the listen to fail the way a dynamic listen can (an unusable bind + // address), since port 0 cannot collide. + const broken = createSandboxController({ + port: 0, + host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface + }); + const result = await broken.start(); + expect(result).toEqual({ port: 0, url: "" }); + expect(broken.getUrl()).toBeNull(); + // close() must be a no-op since the server never bound. + await expect(broken.close()).resolves.toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + "Sandbox server error:", + expect.objectContaining({ code: "EADDRNOTAVAIL" }), + ); + } finally { + errorSpy.mockRestore(); + await controller.close(); + } + }); + + it("gives up after one retry when the dynamic fallback also fails", async () => { + // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could + // re-listen forever instead of settling. + const { port, release } = await claimPort(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); const controller = createSandboxController({ port, host: "127.0.0.1" }); try { const result = await controller.start(); - expect(result).toEqual({ port: 0, url: "" }); - expect(controller.getUrl()).toBeNull(); - // close() must be a no-op since the server never bound. - await expect(controller.close()).resolves.toBeUndefined(); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining(`Sandbox: port ${port} in use`), - ); + // The retry succeeds here (a dynamic port is always available), so assert + // the latch is what bounds it: exactly one retry warning was emitted. + expect( + warnSpy.mock.calls.filter((c) => + String(c[0]).includes("falling back to an OS-assigned port"), + ), + ).toHaveLength(1); + expect(result.port).toBeGreaterThan(0); } finally { + warnSpy.mockRestore(); errorSpy.mockRestore(); - await new Promise<void>((resolve) => blocker.close(() => resolve())); + await controller.close(); + await release(); } }); diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 5436b8fe..7dbb2e4a 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -7,6 +7,7 @@ import { webServerConfigToInitialPayload, type WebServerConfig, } from "../../../../server/web-server-config.js"; +import { DEFAULT_SANDBOX_PORT } from "../../../../server/sandbox-controller.js"; import { API_SERVER_ENV_VARS, LEGACY_AUTH_TOKEN_ENV, @@ -32,7 +33,7 @@ const MUTATED_ENV_KEYS = [ const baseConfig = (): WebServerConfig => ({ port: 6274, - hostname: "localhost", + hostname: "127.0.0.1", authToken: "tok", dangerouslyOmitAuth: false, initialMcpConfig: null, @@ -42,7 +43,7 @@ const baseConfig = (): WebServerConfig => ({ storageDir: undefined, allowedOrigins: ["http://localhost:6274"], sandboxPort: 0, - sandboxHost: "localhost", + sandboxHost: "127.0.0.1", logger: undefined, autoOpen: false, }); @@ -72,7 +73,7 @@ describe("buildWebServerConfigFromEnv", () => { it("returns defaults when no env vars are set", () => { const cfg = buildWebServerConfigFromEnv(); expect(cfg.port).toBe(6274); - expect(cfg.hostname).toBe("localhost"); + expect(cfg.hostname).toBe("127.0.0.1"); expect(cfg.authToken).toBe(""); expect(cfg.dangerouslyOmitAuth).toBe(false); expect(cfg.initialMcpConfig).toBeNull(); @@ -82,8 +83,8 @@ describe("buildWebServerConfigFromEnv", () => { "http://127.0.0.1:6274", "http://[::1]:6274", ]); - expect(cfg.sandboxPort).toBe(0); - expect(cfg.sandboxHost).toBe("localhost"); + expect(cfg.sandboxPort).toBe(DEFAULT_SANDBOX_PORT); + expect(cfg.sandboxHost).toBe("127.0.0.1"); expect(cfg.logger).toBeUndefined(); // Vitest sets `process.env.VITEST = 'true'`, so the autoOpen default is // suppressed here. Real `vite dev` runs don't set VITEST and default to @@ -289,7 +290,9 @@ describe("buildWebServerConfigFromEnv", () => { it("ignores non-numeric SERVER_PORT", () => { process.env.SERVER_PORT = "nope"; - expect(buildWebServerConfigFromEnv().sandboxPort).toBe(0); + expect(buildWebServerConfigFromEnv().sandboxPort).toBe( + DEFAULT_SANDBOX_PORT, + ); }); it("treats MCP_SANDBOX_PORT empty string as unset", () => { @@ -596,7 +599,7 @@ describe("printServerBanner", () => { it("includes the auth token in the URL when auth is enabled", () => { const url = printServerBanner(baseConfig(), 6274, "secret", undefined); expect(url).toBe( - `http://localhost:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, + `http://127.0.0.1:6274?${API_SERVER_ENV_VARS.AUTH_TOKEN}=secret`, ); expect(logSpy.lines.some((l) => l.includes("Auth token: secret"))).toBe( true, @@ -607,7 +610,7 @@ describe("printServerBanner", () => { const cfg = baseConfig(); cfg.dangerouslyOmitAuth = true; const url = printServerBanner(cfg, 6274, "irrelevant", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); expect( logSpy.lines.some((l) => l.includes("Auth: disabled (DANGEROUSLY_OMIT_AUTH)"), @@ -617,7 +620,7 @@ describe("printServerBanner", () => { it("omits the query string when no token is supplied", () => { const url = printServerBanner(baseConfig(), 6274, "", undefined); - expect(url).toBe("http://localhost:6274"); + expect(url).toBe("http://127.0.0.1:6274"); }); it("brackets an IPv6 bind host in the printed URL", () => { diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index a2db3f55..0d1b2fe3 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -66,7 +66,7 @@ Consequences: - `MCP_PROXY_FULL_ADDRESS` has no v2 equivalent and is ignored. It existed to tell the browser where a non-default proxy lived; there is no proxy. - Docker needs `-p 6274:6274` for the UI, where the v1 recipe published `6277` as well. -⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a dynamic port by default. It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a remote host, an SSH tunnel) you must pin it with `MCP_SANDBOX_PORT` and expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +⚠️ **`6274` is not the only listener.** The web backend also starts a **separate MCP Apps sandbox server**, on a fixed **`6275`** by default (it was OS-assigned before #2008). It is only used by the Apps tab, and on plain loopback it needs no attention — but the browser reaches it directly, so anywhere the Inspector is _not_ served from the browser's own machine (Docker, a dev container, a remote host, an SSH tunnel) you must expose/forward that port too, or the Apps tab won't load. This is a different port from v1's proxy — it carries no MCP traffic — but it is still a second port. See [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). ## Launching @@ -280,7 +280,7 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th | `MCP_AUTO_OPEN_ENABLED` | same | Also gates non-TTY interactive OAuth in the CLI/TUI | | — | `DANGEROUSLY_BIND_ALL_INTERFACES` | New opt-in for a wildcard bind (the Docker image sets it) | | — | `MCP_CATALOG_PATH` | Default catalog path | -| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (dynamic by default) | +| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) | | — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations | | — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback | @@ -355,7 +355,9 @@ Notes: **"`Transport type not specified and could not be determined from URL`."** The path ends in neither `/mcp` nor `/sse`, so v2 refuses to guess where v1 fell back to SSE. Pass `--transport http` or `--transport sse`. -**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second, dynamic port. Pin it with `MCP_SANDBOX_PORT` and publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). +**"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). + +**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From 1ea1e30f6344985927cf68657bd583dbf7f928cf Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 13:17:08 -0400 Subject: [PATCH 120/941] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20real=20latch=20coverage,=20consistent=20docker=20do?= =?UTF-8?q?cs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The `retriedDynamic` latch test never made the retry fail: a real `listen(0)` always succeeds, so it produced one EADDRINUSE and passed with the latch deleted. Mock `node:http` so every listen fails, asserting exactly two listen attempts and a settled result. Verified by mutation: removing the latch now hangs the test instead of passing it. - The migration guide's Docker notes still described a dynamic sandbox port and told readers to pin a custom one, contradicting the new fixed 6275 two sections later. - Narrowed "or any container" in the troubleshooting entry, which overstated the scope and contradicted the PR's own scope note. It now names the IPv4-forwarder case and explicitly excludes the official image and a plain loopback -p. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../server/sandbox-controller.test.ts | 58 ++++++++++++++++--- docs/v1-to-v2-migration.md | 9 +-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/integration/server/sandbox-controller.test.ts b/clients/web/src/test/integration/server/sandbox-controller.test.ts index a1f73f7e..6614c06e 100644 --- a/clients/web/src/test/integration/server/sandbox-controller.test.ts +++ b/clients/web/src/test/integration/server/sandbox-controller.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createServer, type Server } from "node:http"; +import { EventEmitter } from "node:events"; import { createSandboxController, DEFAULT_SANDBOX_PORT, @@ -408,27 +409,66 @@ describe("createSandboxController", () => { }); it("gives up after one retry when the dynamic fallback also fails", async () => { - // Guards the `retriedDynamic` latch: without it an EADDRINUSE loop could - // re-listen forever instead of settling. - const { port, release } = await claimPort(); + // Guards the `retriedDynamic` latch. A real `listen(0)` effectively always + // succeeds, so claiming a port only ever produces ONE EADDRINUSE — under + // which this test would pass with the latch deleted. Mock `node:http` so + // every listen fails with EADDRINUSE: the first trips the retry, the second + // must be caught by the latch and settle. Without the latch this recurses + // until the test times out rather than resolving. + vi.resetModules(); + const listenCalls: number[] = []; + vi.doMock("node:http", async () => { + const actual = + await vi.importActual<typeof import("node:http")>("node:http"); + return { + ...actual, + createServer: () => { + const emitter = new EventEmitter(); + return Object.assign(emitter, { + listen: (p: number) => { + listenCalls.push(p); + setImmediate(() => + emitter.emit( + "error", + Object.assign(new Error("in use"), { code: "EADDRINUSE" }), + ), + ); + }, + address: () => null, + close: (cb?: () => void) => cb?.(), + }); + }, + }; + }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const controller = createSandboxController({ port, host: "127.0.0.1" }); try { + const mod = await import("../../../../server/sandbox-controller.js"); + const controller = mod.createSandboxController({ + port: 6275, + host: "127.0.0.1", + }); + // Settles rather than looping — the contract the Vite plugin depends on. const result = await controller.start(); - // The retry succeeds here (a dynamic port is always available), so assert - // the latch is what bounds it: exactly one retry warning was emitted. + expect(result).toEqual({ port: 0, url: "" }); + expect(controller.getUrl()).toBeNull(); + // Exactly two listens: the fixed port, then one dynamic retry. A third + // would mean the latch failed to bound the loop. + expect(listenCalls).toEqual([6275, 0]); expect( warnSpy.mock.calls.filter((c) => String(c[0]).includes("falling back to an OS-assigned port"), ), ).toHaveLength(1); - expect(result.port).toBeGreaterThan(0); + // The second failure degrades loudly instead of retrying again. + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("Sandbox: port 6275 in use"), + ); } finally { warnSpy.mockRestore(); errorSpy.mockRestore(); - await controller.close(); - await release(); + vi.doUnmock("node:http"); + vi.resetModules(); } }); diff --git a/docs/v1-to-v2-migration.md b/docs/v1-to-v2-migration.md index 0d1b2fe3..c241e9df 100644 --- a/docs/v1-to-v2-migration.md +++ b/docs/v1-to-v2-migration.md @@ -330,14 +330,15 @@ docker run --rm -p 127.0.0.1:6274:6274 ghcr.io/modelcontextprotocol/inspector:la Notes: - **Keep the `127.0.0.1:` prefix on the published port**, as the v1 recipe did. `-p 6274:6274` publishes on every host interface, and the container's `HOST=0.0.0.0` is about the _container's_ interfaces, not the host's — the two are independent. That matters here because the backend spawns processes, `/` embeds the API token, and requests arriving with **no** `Origin` header (i.e. anything that isn't a browser) skip the origin allow-list, leaving the token as the only guard. Bind to loopback and opt into wider exposure deliberately. -- **The Apps tab needs one more published port.** The sandbox server is dynamic by default and the `Dockerfile` `EXPOSE`s only `6274`, so the recipe above covers everything _except_ MCP Apps. To use them, pin and publish the sandbox port as well: +- **The Apps tab needs one more published port.** The sandbox server listens on `6275` by default, so the recipe above covers everything _except_ MCP Apps. To use them, publish that port as well: ```bash - docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6280:6280 \ - -e MCP_SANDBOX_PORT=6280 \ + docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \ ghcr.io/modelcontextprotocol/inspector:latest ``` + Publish it on the **same port number** inside and out: the sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it advertises a port the browser can't reach. To use a different number, move both ends — `-e MCP_SANDBOX_PORT=6280 -p 127.0.0.1:6280:6280`. + - The v2 image sets `DANGEROUSLY_BIND_ALL_INTERFACES=true` internally (a container must bind `0.0.0.0` to be reachable through `-p`). Setting a bare `HOST=0.0.0.0` **outside** a container now exits with an error. - **If you remap the published port** (`-p 8080:6274`), the browser's origin no longer matches the in-container port, so set `ALLOWED_ORIGINS=http://localhost:8080,http://127.0.0.1:8080` (or run `-e CLIENT_PORT=8080 -p 8080:8080`) or connects will 403. - The image runs as non-root and has a `HEALTHCHECK` that assumes `--web`; add `--no-healthcheck` when running `--cli` / `--tui`. @@ -357,7 +358,7 @@ Notes: **"The Apps tab is blank in Docker / over SSH."** The MCP Apps sandbox is a second port (`6275` by default). Publish/forward it too — see [MCP Apps caveats](../clients/web/README.md#hosting-on-a-network). -**"The page never loads inside a VS Code dev container (or any container)."** Fixed in the release carrying #1951 — upgrade. Before that fix the default `HOST=localhost` bound IPv6 loopback only on Linux, so the container's port forwarder (which connects over IPv4 `127.0.0.1`) could never reach it; the terminal printed a normal banner while the browser spun forever. On an older version, work around it with `HOST=127.0.0.1`. Note the failure is a **hang**, not a connection error, because the forwarder accepts on the host side before failing to connect inward. +**"The page never loads inside a VS Code dev container."** Fixed in the release carrying #1951 — upgrade; on an older version, work around it with `HOST=127.0.0.1`. Before that fix the default `HOST=localhost` bound IPv6 loopback **only** on Linux, so anything connecting over IPv4 `127.0.0.1` was refused — which is what a dev container's port forwarder does. The terminal printed a normal banner while the browser spun forever; the failure is a **hang** rather than a connection error because the forwarder accepts on the host side before failing to connect inward. The same fix covers an `ssh -L 6274:127.0.0.1:6274` tunnel and a container healthcheck. It does **not** describe the official Docker image (it binds `0.0.0.0`, so it was never affected) or a plain `docker run -p` against a loopback bind, which is unreachable across the network namespace for an unrelated reason. **"`HOST=0.0.0.0` exits with an error."** That's the wildcard-bind guard. Bind a specific address instead, or set `DANGEROUSLY_BIND_ALL_INTERFACES=true` if you genuinely need all interfaces. See [Host binding & the origin allow-list](../clients/web/README.md#host-binding--the-origin-allow-list). From a9ed087f253d35d79b603e45bac2ea816773a7c0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 14:16:03 -0400 Subject: [PATCH 121/941] chore: sync v2/main to 2.2.0 and bump on v2/main before the milestone merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2/main has been at 2.0.0 since the v2.0.0 release while main and npm `latest` are at 2.2.0. The bump was created on the milestone-merge branch, which is cut from main, so it only ever existed downstream of v2/main and nothing carried it back — `chore: 2.1.0` and `chore: 2.2.0` are both on main and neither is on v2/main. That is not cosmetic. A branch cut from a milestone-merge branch silently carries the bump into an unrelated PR — #2009 arrived with a 2.0.0 -> 2.2.0 diff on a container bugfix — and anything reading the version in development (readInspectorVersion, --version, GET /api/config) reported a version two releases old. Move the bump to v2/main, before the milestone merge, so it flows into main with the rest of the milestone's work and the branches never disagree. The release tag is applied to the resulting main commit instead. The publish workflow needs no change: its tag-vs-package.json assertion runs at the release commit on main, which still carries the bump. Deliberately not a back-merge of main into v2/main: main holds the whole pre-v2 v1 history (~230 commits v2/main lacks, retained through `chore: replace main's tree with v2`), so merging would graft all of it into the develop branch's log permanently to deliver a two-file change. Also sets v2/main to 2.2.0 as a one-time catch-up. Nothing in the repo asserts a specific version value, so this is a safe two-file change. Closes #2010 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 4 ++++ README.md | 24 +++++++++++++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4123ccad..9ac732b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,6 +465,10 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version <x> --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + + A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. + - **Project Boards**: - v2 - https://github.com/orgs/modelcontextprotocol/projects/28 (active board — all new work goes here) - v1 - https://github.com/orgs/modelcontextprotocol/projects/11 (legacy inspector version, no new activity except security fixes) diff --git a/README.md b/README.md index 35b1ce6d..9c9f59ee 100644 --- a/README.md +++ b/README.md @@ -326,16 +326,34 @@ Publishing is automated by two release-gated jobs in [`.github/workflows/main.ym A v2 release is cut from **`main`**, after the milestone's work has been merged there from `v2/main` — not from `v2/main` itself. (The v1 line releases independently from `v1/main` to the `v1-latest` tag and never touches `main`; see [Repo status](#mcp-inspector).) -Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is just: +Because there is **one version number** (only the root `package.json` has one — the clients carry none, so there is nothing to keep in sync and no `check-version` step), the release flow is three steps. + +**1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: ```bash -npm version <major|minor|patch> # bumps the root package.json + tags -git push --follow-tags +git checkout -b v2/chore/<issue>-bump-x-y-z v2/main +npm version <major|minor|patch> --no-git-tag-version # bump only, no tag +# PR → v2/main +``` + +⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. + +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. + +**3. Tag the `main` commit and draft the Release:** + +```bash +git checkout main && git pull +git tag v<x.y.z> && git push origin v<x.y.z> # then draft & publish a GitHub Release for that tag → triggers `publish` ``` The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. +**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. + +Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge. + ### Docker A container image is published to GHCR (`ghcr.io/modelcontextprotocol/inspector`, `linux/amd64` + `linux/arm64`) by the release workflow. The [`Dockerfile`](Dockerfile) is a two-stage build: the first stage installs and `npm pack`s the publishable tarball; the second stage `npm install -g`s that tarball, so the image ships the exact same artifact as npm, with a clean `mcp-inspector` bin. diff --git a/package-lock.json b/package-lock.json index 5b738bc6..bb4866ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b255bb28..4e0295ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.0.0", + "version": "2.2.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 9048e69758134145214d0bd8f1229ba6e7d9ffe6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 14:39:30 -0400 Subject: [PATCH 122/941] docs: drop the v prefix from the release tag step This repo's release tags are bare x.y.z (2.2.0, 2.1.0, 2.0.0), not v-prefixed. Also notes why the manual tag matters: npm's tag-version-prefix defaults to 'v' and the repo sets no .npmrc, so a bare `npm version` would have produced a tag that doesn't match the convention. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9c9f59ee..f90ed822 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,12 @@ npm version <major|minor|patch> --no-git-tag-version # bump only, no tag ```bash git checkout main && git pull -git tag v<x.y.z> && git push origin v<x.y.z> +git tag <x.y.z> && git push origin <x.y.z> # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. + The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. **Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. From 4d567c97e4555c357e010340287b26aa2a489c05 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 14:47:33 -0400 Subject: [PATCH 123/941] docs: make the release commands copy-pasteable and correct the invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: - `<x.y.z>` / `<major|minor|patch>` inside a bash block is shell redirection, so both documented commands were syntax errors if copied. Verified: `bash -n` rejects `git tag <x.y.z> && ...` and accepts `git tag 2.3.0 && ...`. Use a concrete 2.2.0 -> 2.3.0 example and say what to substitute. - "agree on the version at every point" / "never disagree" overstated it. The procedure deliberately bumps v2/main first, so between the bump and the milestone merge the branches differ — v2/main reads the version being built, main the one released. Describe the real invariant: they converge when the release merge lands, and v2/main is never left behind main. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ac732b8..0baea459 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -465,7 +465,7 @@ Don't lean on GitHub's permission gate to enforce this. Whether an outside repor So v2 flows `feature branch → v2/main → (milestone) main → npm latest`, while v1 is flat: `feature branch → v1/main → npm v1-latest`, with no merge into `main` at any point. The two lines are published independently under separate dist-tags, which is why a v1 fix does **not** need to be forward-ported to reach users on v1 (`npx @modelcontextprotocol/inspector@v1-latest`). - ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version <x> --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work and the two branches never disagree about the version; the release tag is then applied to the resulting **`main`** commit. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). + ⚠️ **The version bump rides that same flow — it is made on `v2/main`, never on the milestone-merge branch (#2010).** `npm version <x> --no-git-tag-version` lands on `v2/main` *before* the milestone merge, so the bump reaches `main` as part of the milestone's work; the release tag (bare `x.y.z`, no `v` prefix) is then applied to the resulting **`main`** commit. This deliberately leaves a normal in-flight interval where `v2/main` reads the version being built and `main` the one currently released — that difference is expected. The invariant it buys is that they converge when the release merge lands, so `v2/main` is never left *behind* `main` afterwards. Bumping on the milestone-merge branch instead (which is cut from `main`) puts the bump only downstream of `v2/main`, where nothing carries it back — that is how `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases, and how #2009 came to carry an unrelated `2.0.0 → 2.2.0` diff simply because its branch was cut from a milestone-merge branch. **Never close a drift by merging `main` into `v2/main`**: `main` holds the whole pre-v2 v1 history (~230 commits `v2/main` lacks), so a back-merge grafts all of it into the develop branch permanently to deliver a two-file change. Full procedure in the root README's [Cutting a release](./README.md#cutting-a-release). A corollary for **branching**: cut feature branches from **`v2/main`**, never from a milestone-merge branch — the latter carries release-only commits that will show up in your PR's diff. diff --git a/README.md b/README.md index f90ed822..c845c540 100644 --- a/README.md +++ b/README.md @@ -330,21 +330,25 @@ Because there is **one version number** (only the root `package.json` has one **1. Bump on `v2/main`, before the milestone merge.** The bump is part of the milestone's work, so it belongs on the develop branch and flows into `main` with everything else: +Substitute the real issue number and release below — the commands are written to be copy-pasteable as-is (a `2.2.0` → `2.3.0` minor bump): + ```bash -git checkout -b v2/chore/<issue>-bump-x-y-z v2/main -npm version <major|minor|patch> --no-git-tag-version # bump only, no tag +git checkout -b v2/chore/2010-bump-2-3-0 v2/main +npm version minor --no-git-tag-version # or major / patch; bump only, no tag # PR → v2/main ``` ⚠️ **`--no-git-tag-version` is load-bearing.** A bare `npm version` also tags, and the tag would land on a `v2/main` commit — but the release must be cut from `main`, so the tag has to point at the merge commit there (step 3). Tagging here creates a tag on a commit that is never released. -**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so `main` and `v2/main` agree on the version at every point. +**2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. + +Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. **3. Tag the `main` commit and draft the Release:** ```bash git checkout main && git pull -git tag <x.y.z> && git push origin <x.y.z> +git tag 2.3.0 && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` From d0143ef8437fc82b9755c268cd16a72b492dbfc8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 15:04:34 -0400 Subject: [PATCH 124/941] docs: tag origin/main explicitly so a release can't point at a local commit git checkout main && git pull resolves through the maintainer's configured merge/rebase strategy, so a divergent local main can produce or replay local commits. Tagging HEAD there tags a commit that is not on origin/main, and pushing only the tag leaves a release whose commit was never published. git tag <x> origin/main pins the tag to what the remote branch actually points at, regardless of local state. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c845c540..a15210d1 100644 --- a/README.md +++ b/README.md @@ -347,11 +347,13 @@ Between steps 1 and 2 the two branches **do** differ, and that is expected, not **3. Tag the `main` commit and draft the Release:** ```bash -git checkout main && git pull -git tag 2.3.0 && git push origin 2.3.0 +git fetch origin main +git tag 2.3.0 origin/main && git push origin 2.3.0 # then draft & publish a GitHub Release for that tag → triggers `publish` ``` +⚠️ **Tag `origin/main`, not your local `HEAD`.** `git checkout main && git pull` resolves through whatever merge-or-rebase strategy you have configured, so a divergent local `main` can quietly produce or replay local commits. Tagging `HEAD` there tags a commit that is not on `origin/main`, and `git push origin <tag>` pushes only the tag — leaving a release whose commit was never published. Naming `origin/main` explicitly makes the tagged commit exactly what the remote branch points at, regardless of local state. + ⚠️ **No `v` prefix.** This repo's release tags are bare `x.y.z` — `2.2.0`, `2.1.0`, `2.0.0` — so tag `2.3.0`, not `v2.3.0`. Note npm's own `tag-version-prefix` defaults to `v` and the repo sets no `.npmrc`, so a bare `npm version` would have produced a `v`-prefixed tag that does not match the convention. Tagging by hand (step 3) is what keeps it right. The workflow's assert step strips a leading `v` before comparing, so a `v`-prefixed tag would still publish — it would just be inconsistent with every previous release. The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. From 937f1dd51b7310798265f3b303305ca3fe134a67 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 16:10:17 -0400 Subject: [PATCH 125/941] fix: render nullable enum fields as a Select instead of a raw JSON textarea MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool argument that is optional AND explicitly nullable — Zod's `.nullish()` / `.nullable().optional()` — compiles to `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch of the tool-call form's field dispatcher tests a single `type` string, so such a field matched nothing and fell through to the raw-JSON fallback, whose onChange stores the unparseable in-progress text back into state and re-`JSON.stringify`s it on the next render — compounding a fresh layer of escaping on every keystroke. `normalizeUnionType` already existed for the FastMCP `string|null` shape, but it only copied the surviving branch's `type` and dropped everything else, so the `enum` never reached the dispatcher; and nothing called it on a tool input schema — only elicitation requests, via `resolveRefsInMessage`. So: - Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema, as v1.x did. This also collapses nine near-identical blocks into two and picks up the `object`/`array` cases the `type: [X, "null"]` form was missing. - Normalize each field in `SchemaForm.renderField`, so tool, elicitation, and app forms all get it rather than just elicitation. - Give a nullable enum `Select` a clear affordance, so a value can be set back to `null`; a non-nullable enum keeps none, since it would reject it. Adds a `record_shipment` preset and `nullable-fields-http.json` showcase config serving all four nullable scalar shapes, for manual verification. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 7 + .../groups/SchemaForm/SchemaForm.test.tsx | 176 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 21 ++- clients/web/src/utils/schemaUtils.test.ts | 114 ++++++++++++ clients/web/src/utils/schemaUtils.ts | 146 +++++++-------- .../configs/nullable-fields-http.json | 11 ++ test-servers/src/preset-registry.ts | 4 + test-servers/src/test-server-fixtures.ts | 33 ++++ 8 files changed, 428 insertions(+), 84 deletions(-) create mode 100644 test-servers/configs/nullable-fields-http.json diff --git a/README.md b/README.md index a15210d1..82164a98 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | | `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | +| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | | `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | | `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | @@ -227,6 +228,12 @@ Connect (default legacy era), open the Tools tab, and type `get` into **Search t The duplicated copies are appended rather than placed beside their twin on purpose. React matches a leading run of same-key children first, so a head-adjacent duplicate happens to line up and the defect hides; separating the pair is what makes it observable — and it is also the realistic shape, two tool sources concatenated. +#### Nullable arguments + +`nullable-fields-http.json` serves `record_shipment`, whose four arguments are each declared with Zod's `.nullish()` — "optional **and** explicitly nullable". That compiles to `anyOf: [<branch>, { "type": "null" }]`, so the real type (and, for the enum, its `enum` list) sits on a branch rather than at the top level. `get_temp` sits alongside it with a plain, non-nullable `units` enum for comparison. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. + +Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 5b9f064a..3ff41b50 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -811,3 +811,179 @@ describe("SchemaForm", () => { expect(container.firstChild).not.toBeNull(); }); }); + +// #1928: "optional AND explicitly nullable" (Zod's `.nullish()`) compiles to a +// nullable union rather than a plain type. Before the normalization step these +// matched no branch and fell through to the raw-JSON fallback, where every +// keystroke re-escaped the value. +describe("SchemaForm nullable unions", () => { + it("renders a Select for an anyOf string-enum|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + type: { + title: "Type", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.click(screen.getByRole("textbox", { name: "Type" })); + await user.click( + await screen.findByRole("option", { name: "envio", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith({ type: "envio" }); + }); + + it("clears a nullable enum back to null", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + type: { + title: "Type", + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + }; + renderWithMantine( + <SchemaForm + schema={schema} + values={{ type: "envio" }} + onChange={onChange} + />, + ); + // Mantine marks its combobox clear button `aria-hidden` (it is mouse-only, + // `tabIndex={-1}`), so it is only reachable with `hidden: true`. + await user.click(screen.getByRole("button", { hidden: true })); + expect(onChange).toHaveBeenCalledWith({ type: null }); + }); + + it("offers no clear affordance on a non-nullable enum", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + format: { type: "string", title: "Format", enum: ["json"] }, + }, + }; + renderWithMantine( + <SchemaForm + schema={schema} + values={{ format: "json" }} + onChange={vi.fn()} + />, + ); + expect( + screen.queryByRole("button", { hidden: true }), + ).not.toBeInTheDocument(); + }); + + it("renders a TextInput for a type: [string, null] field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + note: { type: ["string", "null"], title: "Note" }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.type(screen.getByLabelText(/Note/), "a"); + expect(onChange).toHaveBeenCalledWith({ note: "a" }); + }); + + it("renders a checkbox for an anyOf boolean|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + enabled: { + title: "Enabled", + anyOf: [{ type: "boolean" }, { type: "null" }], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.click(screen.getByLabelText("Enabled")); + expect(onChange).toHaveBeenCalledWith({ enabled: true }); + }); + + it("renders a MultiSelect for an anyOf array-of-enum|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + tags: { + title: "Tags", + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.click(screen.getByRole("textbox", { name: "Tags" })); + await user.click( + await screen.findByRole("option", { name: "a", hidden: true }), + ); + expect(onChange).toHaveBeenCalledWith({ tags: ["a"] }); + }); + + it("renders nested fields for an anyOf object|null field", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + profile: { + title: "Profile", + anyOf: [ + { + type: "object", + properties: { nick: { type: "string", title: "Nick" } }, + }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.type(screen.getByLabelText(/Nick/), "z"); + expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } }); + }); + + it("still falls back to the JSON input for a union of two real types", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + mixed: { + title: "Mixed", + anyOf: [{ type: "string" }, { type: "number" }], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA"); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 232b5327..eadb0564 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -12,6 +12,7 @@ import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import { normalizeUnionType } from "../../../utils/schemaUtils"; const FieldLabel = Text.withProps({ fw: 500, @@ -51,6 +52,16 @@ function toEnumData( return values; } +/** + * Whether an enum `Select` should offer a clear affordance. Only a schema that + * admits `null` does: clearing emits `null`, and an enum without a null branch + * would reject it. Without this a nullable enum is a one-way door — once a + * value is picked there is no way back to "no answer". + */ +function isClearable(fieldSchema: InspectorFormSchema): boolean { + return fieldSchema.nullable === true; +} + /** * Interpret whatever Mantine's `NumberInput` reported as the JSON value for the * field. Anything that is not a finite number becomes `undefined`, which is how @@ -207,7 +218,13 @@ export function SchemaForm({ onChange({ ...values, [fieldName]: fieldValue }); } - function renderField(fieldName: string, fieldSchema: InspectorFormSchema) { + function renderField(fieldName: string, rawSchema: InspectorFormSchema) { + // Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`) + // before dispatching. Every branch below tests a single `type` string, so + // without this an "optional and explicitly nullable" field — what Zod's + // `.nullish()` emits — matches nothing and falls through to the raw-JSON + // fallback, which is unusable for a value the user has to type (#1928). + const fieldSchema = normalizeUnionType(rawSchema); const isRequired = requiredFields.includes(fieldName); const label = fieldSchema.title ?? fieldName; const description = fieldSchema.description; @@ -223,6 +240,7 @@ export function SchemaForm({ withAsterisk={isRequired} disabled={disabled} data={toEnumData(fieldSchema.enum, fieldSchema.enumNames)} + clearable={isClearable(fieldSchema)} value={(rawValue as string) ?? null} onChange={(val) => handleFieldChange(fieldName, val)} /> @@ -243,6 +261,7 @@ export function SchemaForm({ withAsterisk={isRequired} disabled={disabled} data={data} + clearable={isClearable(fieldSchema)} value={(rawValue as string) ?? null} onChange={(val) => handleFieldChange(fieldName, val)} /> diff --git a/clients/web/src/utils/schemaUtils.test.ts b/clients/web/src/utils/schemaUtils.test.ts index 2689f317..59623fdb 100644 --- a/clients/web/src/utils/schemaUtils.test.ts +++ b/clients/web/src/utils/schemaUtils.test.ts @@ -347,6 +347,120 @@ describe("normalizeUnionType", () => { }; expect(normalizeUnionType(schema)).toBe(schema); }); + + it("ignores a two-member anyOf with no null branch", () => { + const schema = { + anyOf: [{ type: "string" as const }, { type: "number" as const }], + }; + expect(normalizeUnionType(schema)).toBe(schema); + }); + + it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { + const schema = { + anyOf: [{ const: "only" }, { type: "null" as const }], + }; + expect(normalizeUnionType(schema)).toBe(schema); + }); + + // #1928: the enum lives on the surviving branch, and hoisting it is what makes + // the field render as a Select instead of the raw-JSON fallback. + it("hoists enum out of an anyOf string-enum|null branch", () => { + expect( + normalizeUnionType({ + description: "Direction", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "string", + description: "Direction", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: true, + }); + }); + + it("infers string for a typeless enum branch", () => { + expect( + normalizeUnionType({ + anyOf: [{ enum: ["a", "b"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a", "b"], + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists items out of an anyOf array|null branch", () => { + expect( + normalizeUnionType({ + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "array", + items: { type: "string", enum: ["a", "b"] }, + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists properties out of an anyOf object|null branch", () => { + expect( + normalizeUnionType({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf object|null regardless of branch order", () => { + expect( + normalizeUnionType({ + anyOf: [{ type: "null" }, { type: "object" }], + }), + ).toEqual({ type: "object", anyOf: undefined, nullable: true }); + }); + + it("normalizes type array object|null", () => { + expect( + normalizeUnionType({ + type: ["object", "null"], + properties: { a: { type: "string" } }, + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + nullable: true, + }); + }); + + it("normalizes type array array|null", () => { + expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({ + type: "array", + nullable: true, + }); + }); + + it("keeps a type array of two non-null members unchanged", () => { + const schema = { type: ["string", "number"] as const }; + expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({ + type: ["string", "number"], + }); + }); }); describe("formatFieldLabel", () => { diff --git a/clients/web/src/utils/schemaUtils.ts b/clients/web/src/utils/schemaUtils.ts index 4e578a1c..2624fba1 100644 --- a/clients/web/src/utils/schemaUtils.ts +++ b/clients/web/src/utils/schemaUtils.ts @@ -201,101 +201,81 @@ export function resolveRef( } /** - * Normalizes union types (like string|null from FastMCP) to simple types for form rendering + * The `type` values {@link SchemaForm} dispatches on. `"null"` is deliberately + * absent: it is the branch a nullable union is being flattened *away* from, and + * there is no widget for a field whose only permitted value is `null`. + */ +const RENDERABLE_TYPES = [ + "string", + "number", + "integer", + "boolean", + "array", + "object", +] as const; + +type RenderableType = (typeof RENDERABLE_TYPES)[number]; + +function isRenderableType(type: unknown): type is RenderableType { + return RENDERABLE_TYPES.includes(type as RenderableType); +} + +/** + * Normalizes a nullable union (`string|null` from FastMCP, or anything Zod's + * `.nullish()` / `.nullable()` compiles to) into the plain type the form + * renderer dispatches on. + * + * Two encodings mean the same thing and are both flattened to + * `{ type: <T>, nullable: true }`: + * + * - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are + * hoisted onto the result, because that is where the detail the renderer + * needs lives. A nullable enum compiles to + * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting + * `enum` is what makes it a `Select` instead of falling through to the raw + * JSON fallback (#1928). The branch also wins over the wrapper on any shared + * key, matching v1.x's behavior. + * - `type: [<T>, "null"]` — the keywords already sit at the top level, so only + * `type` collapses. + * + * Anything else — a union of two real types, a three-member `anyOf`, a branch + * whose type has no widget — is returned untouched and renders through the + * JSON fallback, which is the honest representation of a shape the form cannot + * model. + * * @param schema The JSON schema to normalize - * @returns A normalized schema or the original schema + * @returns A normalized schema, or the original schema when no nullable-union + * pattern matches */ export function normalizeUnionType( schema: InspectorFormSchema, ): InspectorFormSchema { - // Handle anyOf with exactly string and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "string") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "string", anyOf: undefined, nullable: true }; + if (schema.anyOf && schema.anyOf.length === 2) { + // `JsonSchemaConst` is structurally a subset of `InspectorFormSchema` (it + // just carries no `type`), so reading both as the latter is safe. + const branches = schema.anyOf as InspectorFormSchema[]; + const nullBranch = branches.find((branch) => branch?.type === "null"); + const branch = branches.find((candidate) => candidate?.type !== "null"); + + if (nullBranch && branch) { + // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows + // that, and every value such a schema admits here is a string. + const type = branch.type ?? (branch.enum ? "string" : undefined); + if (isRenderableType(type)) { + return { ...schema, ...branch, type, anyOf: undefined, nullable: true }; + } + } } - // Handle anyOf with exactly boolean and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "boolean") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "boolean", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly number and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "number") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "number", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly integer and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "integer") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "integer", anyOf: undefined, nullable: true }; - } - - // Handle anyOf with exactly array and null (FastMCP pattern) - if ( - schema.anyOf && - schema.anyOf.length === 2 && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "array") && - schema.anyOf.some((t) => (t as InspectorFormSchema).type === "null") - ) { - return { ...schema, type: "array", anyOf: undefined, nullable: true }; - } - - // Handle array type with exactly string and null if ( Array.isArray(schema.type) && schema.type.length === 2 && - schema.type.includes("string") && schema.type.includes("null") ) { - return { ...schema, type: "string", nullable: true }; - } - - // Handle array type with exactly boolean and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("boolean") && - schema.type.includes("null") - ) { - return { ...schema, type: "boolean", nullable: true }; - } - - // Handle array type with exactly number and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("number") && - schema.type.includes("null") - ) { - return { ...schema, type: "number", nullable: true }; - } - - // Handle array type with exactly integer and null - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("integer") && - schema.type.includes("null") - ) { - return { ...schema, type: "integer", nullable: true }; + const type = schema.type.find((member) => member !== "null"); + if (isRenderableType(type)) { + return { ...schema, type, nullable: true }; + } } return schema; diff --git a/test-servers/configs/nullable-fields-http.json b/test-servers/configs/nullable-fields-http.json new file mode 100644 index 00000000..91c4b647 --- /dev/null +++ b/test-servers/configs/nullable-fields-http.json @@ -0,0 +1,11 @@ +{ + "serverInfo": { + "name": "nullable-fields-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "record_shipment" }, { "preset": "get_temp" }], + "transport": { + "type": "streamable-http", + "port": 6602 + } +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 806769b3..d78998ec 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -12,6 +12,7 @@ import type { } from "./composable-test-server.js"; import { createEchoTool, + createNullableFieldsTool, createGetEnvTool, createAddTool, createGetSumTool, @@ -91,6 +92,9 @@ function resolveToolPreset( switch (name) { case "echo": return createEchoTool(); + case "record_shipment": + case "nullable_fields": + return createNullableFieldsTool(); case "get-env": case "get_env": return createGetEnvTool(); diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 5a05b07b..070b5750 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -162,6 +162,39 @@ export function createEchoTool(): ToolDefinition { }; } +/** + * Create a "record_shipment" tool whose arguments are all **nullable** — the + * shape Zod's `.nullish()` / `.nullable()` compiles to, where the real type + * sits inside `anyOf: [<branch>, { type: "null" }]` rather than at the top + * level. + * + * `direction` is the case from #1928: a nullable *enum*, whose `enum` keyword + * lives on the surviving branch. Before the fix nothing in the tool-call form + * matched such a field, so it fell through to the raw-JSON textarea and + * re-escaped its own value on every keystroke. The other three cover the + * remaining scalar branches so a regression in one is visible next to a + * working sibling. + */ +export function createNullableFieldsTool(): ToolDefinition { + return { + name: "record_shipment", + description: + "Record a shipment. Every argument is optional AND explicitly nullable (Zod .nullish()), so each compiles to an anyOf union with a null branch.", + inputSchema: { + direction: z + .enum(["envio", "recebimento"]) + .nullish() + .describe("Direction of the shipment (nullable enum — #1928)"), + reference: z.string().nullish().describe("Free-text reference"), + quantity: z.number().int().nullish().describe("Number of packages"), + express: z.boolean().nullish().describe("Ship express"), + }, + handler: async (params: Record<string, unknown>) => { + return toToolResult(JSON.stringify(params, null, 2)); + }, + }; +} + /** * Create a "get-env" tool matching @modelcontextprotocol/server-everything. * Returns the server process environment as pretty-printed JSON text. From fe5b622fd143b37f94535eafaa19ab2f8972a6f8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 16:52:11 -0400 Subject: [PATCH 126/941] fix: salvage a list result's valid entries when one is malformed (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK validates a list result as a whole, so a single non-conforming entry rejected the entire response and the panel showed "Couldn't load resources" with zero items — the exact opposite of what a debugging tool should do with a server that is 90% right. Reported against a PHP server whose empty `annotations` object reaches the wire as `[]` rather than `{}`. `listAll*` now falls back to a lenient re-walk when — and only when — the SDK rejects a result it received (`InvalidResult` / `UnsupportedResultType`), keeps the entries that validate, and reports the rest. A conforming server takes the strict, cache-aware path exactly as before. Salvaging is not accepting: the dropped entries are named with their reason in a warning above the list, and the Protocol entry is still marked rejected, so the wire truth survives. When every entry validates, the original error is rethrown rather than swallowed — the rejection was about something the per-item pass cannot explain. `isClientDecodeRejection` moves to the new module and is now shared with managedListState, which had the only copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 6 +- clients/web/src/App.test.tsx | 1 + clients/web/src/App.tsx | 2 + .../MalformedItemsWarning.stories.tsx | 89 +++++ .../MalformedItemsWarning.test.tsx | 97 ++++++ .../MalformedItemsWarning.tsx | 99 ++++++ .../groups/PromptControls/PromptControls.tsx | 14 + .../ResourceControls/ResourceControls.tsx | 19 ++ .../groups/ToolControls/ToolControls.tsx | 14 + .../screens/PromptsScreen/PromptsScreen.tsx | 8 + .../ResourcesScreen/ResourcesScreen.tsx | 8 + .../screens/ToolsScreen/ToolsScreen.tsx | 8 + .../views/InspectorView/InspectorView.tsx | 10 + .../web/src/test/core/mcp/listSalvage.test.ts | 232 +++++++++++++ .../inspectorClient-malformed-list.test.ts | 320 ++++++++++++++++++ core/mcp/__tests__/fakeInspectorClient.ts | 13 + core/mcp/index.ts | 3 + core/mcp/inspectorClient.ts | 253 ++++++++++++-- core/mcp/inspectorClientEventTarget.ts | 7 + core/mcp/inspectorClientProtocol.ts | 4 + core/mcp/listSalvage.ts | 199 +++++++++++ core/mcp/state/managedListState.ts | 34 +- core/react/useInspectorClient.ts | 26 ++ 23 files changed, 1402 insertions(+), 64 deletions(-) create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx create mode 100644 clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx create mode 100644 clients/web/src/test/core/mcp/listSalvage.test.ts create mode 100644 clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts create mode 100644 core/mcp/listSalvage.ts diff --git a/AGENTS.md b/AGENTS.md index 0baea459..a96c06da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,11 @@ v2/main/ │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, -│ │ # used by the raw-wire tasks/* channel — #1631) +│ │ # used by the raw-wire tasks/* channel — #1631; +│ │ # listSalvage.ts: per-item salvage for list results — +│ │ # keeps the valid entries when one is non-conforming +│ │ # instead of losing the whole list, and owns the +│ │ # shared isClientDecodeRejection predicate — #1909) │ │ ├── import/ # Config import strategies (#1348): client-config parsers │ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry │ │ │ # server.json parser, strategy registry + well-known diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 0869d0d5..8ca842ed 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -598,6 +598,7 @@ const DEFAULT_USE_INSPECTOR_CLIENT: ReturnType<typeof useInspectorClient> = { serverInfo: undefined, instructions: undefined, excludedTools: [], + malformedListItems: [], appRendererClient: null, connect: vi.fn().mockResolvedValue(undefined), disconnect: vi.fn().mockResolvedValue(undefined), diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 509fc079..b42a2e3b 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -990,6 +990,7 @@ function App() { protocolEra, discoverResult, excludedTools, + malformedListItems, lastError, } = useInspectorClient(inspectorClient); const { @@ -4376,6 +4377,7 @@ function App() { latencyMs={latencyMs} tools={tools} excludedTools={excludedTools} + malformedListItems={malformedListItems} prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx new file mode 100644 index 00000000..756e8667 --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { MalformedItemsWarning } from "./MalformedItemsWarning"; + +const meta: Meta<typeof MalformedItemsWarning> = { + title: "Elements/MalformedItemsWarning", + component: MalformedItemsWarning, +}; + +export default meta; +type Story = StoryObj<typeof MalformedItemsWarning>; + +/** The failure this was built for: a PHP server whose empty `annotations` + * object reaches the wire as `[]` (#1909). */ +export const OneDroppedTemplate: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input: expected object, received array", + }, + ], + }, +}; + +export const SeveralDropped: Story = { + args: { + method: "tools/list", + what: "tools", + items: [ + { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", + }, + { + method: "tools/list", + index: 4, + label: "add", + reason: "name: Invalid input: expected string, received number", + }, + ], + }, +}; + +/** An entry too broken to carry a name is reported by its position. */ +export const UnlabeledEntry: Story = { + args: { + method: "prompts/list", + what: "prompts", + items: [ + { + method: "prompts/list", + index: 2, + reason: "Invalid input: expected object, received null", + }, + ], + }, +}; + +/** Entries belonging to another list method are ignored — the same set is + * passed to every panel, and each selects its own. */ +export const OtherMethodOnly: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [ + { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", + }, + ], + }, +}; + +/** The resting state: a conforming server, nothing rendered. */ +export const Conforming: Story = { + args: { + method: "resources/templates/list", + what: "resource templates", + items: [], + }, +}; diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx new file mode 100644 index 00000000..ed8c03f0 --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.test.tsx @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import type { MalformedListItem } from "@inspector/core/mcp"; +import { renderWithMantine } from "../../../test/renderWithMantine"; +import { MalformedItemsWarning } from "./MalformedItemsWarning"; + +const TEMPLATE_ENTRY: MalformedListItem = { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input: expected object, received array", +}; + +const TOOL_ENTRY: MalformedListItem = { + method: "tools/list", + index: 0, + label: "get_weather", + reason: "inputSchema: Invalid input: expected object, received string", +}; + +describe("MalformedItemsWarning", () => { + it("names the dropped entry and why it was dropped", () => { + renderWithMantine( + <MalformedItemsWarning + items={[TEMPLATE_ENTRY]} + method="resources/templates/list" + what="resource templates" + />, + ); + + expect(screen.getByText("1 malformed entry dropped")).toBeInTheDocument(); + expect(screen.getByText("array_annotations")).toBeInTheDocument(); + expect(screen.getByText(TEMPLATE_ENTRY.reason)).toBeInTheDocument(); + }); + + it("renders nothing when the server was conforming", () => { + renderWithMantine( + <MalformedItemsWarning + items={[]} + method="resources/templates/list" + what="resource templates" + />, + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("ignores entries belonging to another list method", () => { + // Every panel receives the whole set and selects its own — a tools failure + // must not surface above the templates list. + renderWithMantine( + <MalformedItemsWarning + items={[TOOL_ENTRY]} + method="resources/templates/list" + what="resource templates" + />, + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByText("get_weather")).not.toBeInTheDocument(); + }); + + it("pluralizes the count and lists every entry", () => { + renderWithMantine( + <MalformedItemsWarning + items={[ + TOOL_ENTRY, + { ...TOOL_ENTRY, index: 4, label: "add", reason: "name: bad" }, + TEMPLATE_ENTRY, + ]} + method="tools/list" + what="tools" + />, + ); + + expect(screen.getByText("2 malformed entries dropped")).toBeInTheDocument(); + expect(screen.getByText("get_weather")).toBeInTheDocument(); + expect(screen.getByText("add")).toBeInTheDocument(); + // The template entry belongs to the other panel. + expect(screen.queryByText("array_annotations")).not.toBeInTheDocument(); + }); + + it("falls back to the position when the entry had no usable name", () => { + renderWithMantine( + <MalformedItemsWarning + items={[ + { + method: "prompts/list", + index: 2, + reason: "Invalid input: expected object, received null", + }, + ]} + method="prompts/list" + what="prompts" + />, + ); + expect(screen.getByText("entry 2")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx new file mode 100644 index 00000000..26bc5d5c --- /dev/null +++ b/clients/web/src/components/elements/MalformedItemsWarning/MalformedItemsWarning.tsx @@ -0,0 +1,99 @@ +import { Alert, Code, List, ScrollArea, Text } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; + +export interface MalformedItemsWarningProps { + /** + * Every malformed entry the client is currently reporting, across all list + * methods — pass the hook's set straight through; the component selects its + * own by {@link method}. + */ + items: MalformedListItem[]; + /** The list method this panel renders, e.g. `"resources/templates/list"`. */ + method: string; + /** What the entries are, for the alert title — e.g. "resource templates". */ + what: string; +} + +// Yellow, not red: unlike ListLoadError this is not a failure — the list DID +// load and is rendered below. It reports what the server sent that the +// Inspector could not accept. +const WarningAlert = Alert.withProps({ + color: "yellow", + variant: "light", +}); + +// The entry list stays compact; the reasons are the payload, so they get the +// monospace treatment while the labels stay in body type. +const EntryList = List.withProps({ + size: "xs", + spacing: 4, + withPadding: true, +}); + +const EntryReason = Code.withProps({ + variant: "wrapping", +}); + +// A server can be wrong about many entries at once; cap the height so a badly +// broken list can't push the list itself off the sidebar. +const EntryScroll = ScrollArea.withProps({ + mah: 180, + type: "auto", +}); + +const SummaryText = Text.withProps({ + size: "xs", + mb: 4, +}); + +// The entry's identity, emphasized against its monospaced reason. +const EntryLabel = Text.withProps({ + component: "span", + size: "xs", + fw: 600, +}); + +/** `label` when the entry had one, else its position in the response. */ +function describeEntry(item: MalformedListItem): string { + return item.label ?? `entry ${item.index}`; +} + +/** + * "Some entries were dropped" — the visible half of the #1909 salvage. + * + * The client keeps the valid entries of a list whose result failed schema + * validation, rather than losing the whole list to one bad entry. Dropping them + * silently would trade one lie for another, so what was dropped, and why, is + * reported here beside the list that rendered without them. + */ +export function MalformedItemsWarning({ + items, + method, + what, +}: MalformedItemsWarningProps) { + const mine = items.filter((item) => item.method === method); + if (mine.length === 0) return null; + + return ( + <WarningAlert + title={`${mine.length} malformed ${ + mine.length === 1 ? "entry" : "entries" + } dropped`} + > + <SummaryText> + The server returned {what} that don't match the MCP schema. The + rest of the list is shown below. + </SummaryText> + <EntryScroll> + <EntryList> + {mine.map((item) => ( + <List.Item key={`${item.method}:${item.index}`}> + <EntryLabel>{describeEntry(item)}</EntryLabel>{" "} + <EntryReason>{item.reason}</EntryReason> + </List.Item> + ))} + </EntryList> + </EntryScroll> + </WarningAlert> + ); +} diff --git a/clients/web/src/components/groups/PromptControls/PromptControls.tsx b/clients/web/src/components/groups/PromptControls/PromptControls.tsx index 2a2d5364..2fbb5633 100644 --- a/clients/web/src/components/groups/PromptControls/PromptControls.tsx +++ b/clients/web/src/components/groups/PromptControls/PromptControls.tsx @@ -3,6 +3,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { Prompt } from "@modelcontextprotocol/client"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -41,6 +43,12 @@ export interface PromptControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from this list because they failed the MCP + * schema. Rendered as a warning above the list, which still shows the rest + * (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; @@ -54,6 +62,7 @@ export function PromptControls({ searchText = "", listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -83,6 +92,11 @@ export function PromptControls({ /> <ListPaginationControls {...pagination} /> <ListLoadError error={loadError} what="prompts" onRetry={onRefreshList} /> + <MalformedItemsWarning + items={malformedListItems} + method="prompts/list" + what="prompts" + /> <ListScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredPrompts.map((prompt) => ( diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx index adf5aa1a..6a319fb1 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.tsx @@ -14,6 +14,8 @@ import { isModernEra } from "../../elements/EraBadge/eraUtils"; import { SubscriptionStreamBadge } from "../../elements/SubscriptionStreamBadge/SubscriptionStreamBadge"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -73,6 +75,12 @@ export interface ResourceControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from `resources/list` or + * `resources/templates/list` because they failed the MCP schema. Rendered as + * a warning above the list, which still shows the rest (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls for the Resources list (#1721). */ pagination: ListPaginationControlsProps; @@ -117,6 +125,7 @@ export function ResourceControls({ openSections: controlledOpenSections, listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -248,6 +257,16 @@ export function ResourceControls({ what="resources" onRetry={onRefreshList} /> + <MalformedItemsWarning + items={malformedListItems} + method="resources/list" + what="resources" + /> + <MalformedItemsWarning + items={malformedListItems} + method="resources/templates/list" + what="resource templates" + /> {/* Stays inline: Accordion is a compound, `multiple`-discriminated generic, so `.withProps({ multiple: true, ... })` loses its JSX call signature (same tooling limit as Box). */} diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 4ad70d85..01ad1f04 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -15,6 +15,8 @@ import type { Tool } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "@inspector/core/mcp/types.js"; import { ListChangedIndicator } from "../../elements/ListChangedIndicator/ListChangedIndicator"; import { ListLoadError } from "../../elements/ListLoadError/ListLoadError"; +import { MalformedItemsWarning } from "../../elements/MalformedItemsWarning/MalformedItemsWarning"; +import type { MalformedListItem } from "@inspector/core/mcp"; import { ListPaginationControls, type ListPaginationControlsProps, @@ -37,6 +39,12 @@ export interface ToolControlsProps { * A failed list load, surfaced above the list instead of leaving the panel * empty (which reads as "this server has none") (#1953). */ + /** + * Entries the client dropped from this list because they failed the MCP + * schema. Rendered as a warning above the list, which still shows the rest + * (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; @@ -127,6 +135,7 @@ export function ToolControls({ searchText = "", listChanged, onRefreshList, + malformedListItems = [], loadError, pagination, onSearchChange, @@ -163,6 +172,11 @@ export function ToolControls({ /> <ListPaginationControls {...pagination} /> <ListLoadError error={loadError} what="tools" onRetry={onRefreshList} /> + <MalformedItemsWarning + items={malformedListItems} + method="tools/list" + what="tools" + /> <SidebarScroll viewportRef={viewportRef}> <Stack gap="xs"> {filteredTools.map(({ tool, key }) => ( diff --git a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx index c599ed47..8b491cc8 100644 --- a/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx +++ b/clients/web/src/components/screens/PromptsScreen/PromptsScreen.tsx @@ -8,6 +8,7 @@ import { Stack, Text, } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { GetPromptResult, Prompt } from "@modelcontextprotocol/client"; import { PromptControls } from "../../groups/PromptControls/PromptControls"; import type { ListPaginationControlsProps } from "../../elements/ListPaginationControls/ListPaginationControls"; @@ -34,6 +35,11 @@ export interface PromptsScreenProps { onUiChange: (next: PromptsUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ + /** + * Entries dropped from this screen's list result(s) as malformed; the list + * panel warns about them above the list (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; @@ -138,6 +144,7 @@ export function PromptsScreen({ completionsSupported, onUiChange, onRefreshList, + malformedListItems, loadError, pagination, onGetPrompt, @@ -277,6 +284,7 @@ export function PromptsScreen({ listChanged={listChanged} onRefreshList={onRefreshList} loadError={loadError} + malformedListItems={malformedListItems} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onSelectPrompt={handleSelectPrompt} diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx index a94f9169..15c4a838 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.tsx @@ -8,6 +8,7 @@ import { Stack, Text, } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { ProtocolEra, ReadResourceResult, @@ -58,6 +59,11 @@ export interface ResourcesScreenProps { onUiChange: (next: ResourcesUiState) => void; onRefreshList: () => void; /** A failed list load, rendered above the sidebar list (#1953). */ + /** + * Entries dropped from this screen's list result(s) as malformed; the list + * panel warns about them above the list (#1909). + */ + malformedListItems?: MalformedListItem[]; loadError?: Error | null; /** Pagination controls rendered in the sidebar (#1721). */ pagination: ListPaginationControlsProps; @@ -172,6 +178,7 @@ export function ResourcesScreen({ subscriptionsSupported = true, onUiChange, onRefreshList, + malformedListItems, loadError, pagination, onReadResource, @@ -329,6 +336,7 @@ export function ResourcesScreen({ listChanged={listChanged} onRefreshList={onRefreshList} loadError={loadError} + malformedListItems={malformedListItems} pagination={pagination} onSearchChange={(value) => onUiChange({ ...ui, search: value })} onOpenSectionsChange={(value) => diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index f6dd4ed3..a8afeb76 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -1,4 +1,5 @@ import { Card, Flex, Stack, Text } from "@mantine/core"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { CallToolResult, ReadResourceResult, @@ -50,6 +51,11 @@ export interface ToolsScreenProps { /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` * annotations (SEP-2243), shown in the sidebar with the reason (#1632). */ excludedTools?: ExcludedTool[]; + /** + * Entries dropped from a list result as malformed, across every list method. + * Each list panel filters for its own and warns about them (#1909). + */ + malformedListItems?: MalformedListItem[]; callState?: ToolCallState; ui: ToolsUiState; listChanged: boolean; @@ -147,6 +153,7 @@ const EmptyState = Text.withProps({ export function ToolsScreen({ tools, excludedTools, + malformedListItems, callState, ui, listChanged, @@ -191,6 +198,7 @@ export function ToolsScreen({ <ToolControls tools={tools} excludedTools={excludedTools} + malformedListItems={malformedListItems} selectedName={selectedToolName} searchText={search} listChanged={listChanged} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 6d162fbf..281ccb30 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -6,6 +6,7 @@ import { type ReactNode, type Ref, } from "react"; +import type { MalformedListItem } from "@inspector/core/mcp"; import type { DeepLink, DeepLinkParseStatus } from "../../../utils/deepLink"; import { AppShell, @@ -418,6 +419,11 @@ export interface InspectorViewProps { /** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations * (SEP-2243); shown in the Tools sidebar with the reason (#1632). */ excludedTools?: ExcludedTool[]; + /** + * Entries dropped from a list result as malformed, across every list method. + * Each list panel filters for its own and warns about them (#1909). + */ + malformedListItems?: MalformedListItem[]; prompts: Prompt[]; resources: Resource[]; resourceTemplates: ResourceTemplate[]; @@ -632,6 +638,7 @@ export function InspectorView({ latencyMs, tools, excludedTools = [], + malformedListItems = [], prompts, resources, resourceTemplates, @@ -1365,6 +1372,7 @@ export function InspectorView({ <ToolsScreen tools={tools} excludedTools={excludedTools} + malformedListItems={malformedListItems} callState={toolCallState} ui={toolsUi} listChanged={toolsListChanged} @@ -1405,6 +1413,7 @@ export function InspectorView({ <ScreenStage active={activeTab === "Prompts"}> <PromptsScreen prompts={prompts} + malformedListItems={malformedListItems} getPromptState={getPromptState} ui={promptsUi} listChanged={promptsListChanged} @@ -1421,6 +1430,7 @@ export function InspectorView({ <ScreenStage active={activeTab === "Resources"}> <ResourcesScreen resources={resources} + malformedListItems={malformedListItems} templates={resourceTemplates} subscriptions={subscriptions} subscriptionStreamState={subscriptionStreamState} diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts new file mode 100644 index 00000000..08445051 --- /dev/null +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v4"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { + LenientListPageSchema, + describeIssues, + isClientDecodeRejection, + labelForRawItem, + rawItemsOf, + salvageListItems, + summarizeMalformed, +} from "@inspector/core/mcp/listSalvage.js"; + +/** + * Per-item list salvage (#1909). + * + * These cover the pure half — deciding what is salvageable and how it is + * described. The client-side half (fall back only on a decode rejection, keep + * the strict path otherwise) is driven end to end against a real malformed + * server in `src/test/integration/mcp/inspectorClient-malformed-list.test.ts`. + */ + +const ItemSchema = z.object({ + name: z.string(), + annotations: z.object({ priority: z.number().optional() }).optional(), +}); + +describe("isClientDecodeRejection", () => { + it("is true for a result the client received and refused", () => { + const err = new SdkError(SdkErrorCode.InvalidResult, "bad result"); + expect(isClientDecodeRejection(err)).toBe(true); + }); + + it("is true for a result type the codec cannot handle", () => { + const err = new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"); + expect(isClientDecodeRejection(err)).toBe(true); + }); + + it("is false for a failure that produced no result to salvage", () => { + // A transport drop / timeout: re-listing would just fail again, and there + // is no response frame to pick entries out of. + expect(isClientDecodeRejection(new Error("socket hang up"))).toBe(false); + expect( + isClientDecodeRejection( + new SdkError(SdkErrorCode.RequestTimeout, "timed out"), + ), + ).toBe(false); + }); +}); + +describe("LenientListPageSchema", () => { + it("keeps the primitive array unvalidated while typing the cursor", () => { + const parsed = LenientListPageSchema.parse({ + resourceTemplates: [{ name: "ok" }, { name: 42 }], + nextCursor: "page-2", + }); + expect(parsed.nextCursor).toBe("page-2"); + expect(rawItemsOf(parsed, "resourceTemplates")).toHaveLength(2); + }); + + it("rejects a page whose cursor is malformed", () => { + // The walk itself depends on `nextCursor`, so a bad one is not salvageable + // — the strict error should stand rather than a partial list being kept. + expect( + LenientListPageSchema.safeParse({ tools: [], nextCursor: 7 }).success, + ).toBe(false); + }); + + it("reads an absent or non-array items key as no entries", () => { + expect(rawItemsOf({}, "tools")).toEqual([]); + expect( + rawItemsOf( + LenientListPageSchema.parse({ tools: "not-a-list" }) as never, + "tools", + ), + ).toEqual([]); + }); +}); + +describe("labelForRawItem", () => { + it("prefers a name, then a uriTemplate, uri, or title", () => { + expect(labelForRawItem({ name: "get_weather" })).toBe("get_weather"); + expect(labelForRawItem({ uriTemplate: "file:///{path}" })).toBe( + "file:///{path}", + ); + expect(labelForRawItem({ uri: "test://one" })).toBe("test://one"); + expect(labelForRawItem({ title: "Titled" })).toBe("Titled"); + }); + + it("has no label for an entry too broken to carry one", () => { + expect(labelForRawItem(null)).toBeUndefined(); + expect(labelForRawItem("just a string")).toBeUndefined(); + expect(labelForRawItem(7)).toBeUndefined(); + expect(labelForRawItem({ name: "" })).toBeUndefined(); + expect(labelForRawItem({ name: 42 })).toBeUndefined(); + }); +}); + +describe("describeIssues", () => { + it("names the offending field so the reader doesn't have to hunt", () => { + const result = ItemSchema.safeParse({ name: "t", annotations: [] }); + expect(result.success).toBe(false); + if (result.success) return; + expect(describeIssues(result.error)).toMatch( + /^annotations: .*expected object/i, + ); + }); + + it("names the expected type even when zod's message omits it", () => { + // The browser build reports a bare "Invalid input" where Node spells out + // "expected object, received array" — without the expected type the warning + // says something is wrong but not what would be right. + const terse = new z.ZodError([ + { + code: "invalid_type", + expected: "object", + path: ["annotations"], + message: "Invalid input", + // Double cast: the terse-message shape is what zod's BROWSER build + // emits, and no parse run under Node can produce it — the issue has to + // be hand-built, and `$ZodIssue`'s union doesn't accept a literal. + } as unknown as z.core.$ZodIssue, + ]); + expect(describeIssues(terse)).toBe( + "annotations: Invalid input (expected object)", + ); + }); + + it("does not repeat an expected type the message already carries", () => { + const result = ItemSchema.safeParse({ name: "t", annotations: [] }); + expect(result.success).toBe(false); + if (result.success) return; + const described = describeIssues(result.error); + expect(described.match(/expected object/g)).toHaveLength(1); + }); + + it("omits the path when the failure is the entry itself", () => { + const result = ItemSchema.safeParse("not an object"); + expect(result.success).toBe(false); + if (result.success) return; + const described = describeIssues(result.error); + expect(described).not.toMatch(/^:/); + expect(described).toMatch(/expected object/i); + }); +}); + +describe("salvageListItems", () => { + it("keeps the valid entries and reports only the broken one", () => { + const { valid, malformed } = salvageListItems({ + method: "resources/templates/list", + items: [ + { name: "empty_annotations", annotations: {} }, + { name: "array_annotations", annotations: [] }, + { name: "full_annotations", annotations: { priority: 0.8 } }, + ], + schema: ItemSchema, + }); + + expect(valid.map((item) => item.name)).toEqual([ + "empty_annotations", + "full_annotations", + ]); + expect(malformed).toEqual([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: expect.stringMatching(/^annotations: /), + }, + ]); + }); + + it("reports an unlabelable entry by its position", () => { + const { valid, malformed } = salvageListItems({ + method: "tools/list", + items: [null], + schema: ItemSchema, + }); + expect(valid).toEqual([]); + expect(malformed[0]).toMatchObject({ index: 0 }); + expect(malformed[0]).not.toHaveProperty("label"); + }); + + it("indexes against the aggregated list, not the page", () => { + // Page 2 of a paginated walk: an index that restarted per page would point + // at the wrong entry in the list the user is looking at. + const { malformed } = salvageListItems({ + method: "tools/list", + items: [{ name: "ok" }, { name: 5 }], + schema: ItemSchema, + startIndex: 10, + }); + expect(malformed[0]?.index).toBe(11); + }); + + it("salvages nothing from a wholly conforming page", () => { + const { valid, malformed } = salvageListItems({ + method: "prompts/list", + items: [{ name: "a" }, { name: "b" }], + schema: ItemSchema, + }); + expect(valid).toHaveLength(2); + expect(malformed).toEqual([]); + }); +}); + +describe("summarizeMalformed", () => { + it("names the single dropped entry and why", () => { + expect( + summarizeMalformed([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: expected object, received array", + }, + ]), + ).toBe( + "Dropped 1 malformed entry — array_annotations: annotations: expected object, received array", + ); + }); + + it("counts the rest when several failed, falling back to the index", () => { + const summary = summarizeMalformed([ + { method: "tools/list", index: 0, reason: "expected object" }, + { method: "tools/list", index: 3, reason: "expected object" }, + ]); + expect(summary).toContain("Dropped 2 malformed entries"); + expect(summary).toContain("index 0"); + expect(summary).toContain("(+1 more)"); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts new file mode 100644 index 00000000..d864e9b1 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { MessageLogState } from "@inspector/core/mcp/state/index.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; + +/** + * Per-item list salvage against a real connection (#1909). + * + * The reported server was PHP, whose empty `annotations` object reaches the + * wire as `[]` rather than `{}` — one non-conforming entry, and the SDK + * rejected the whole `resources/templates/list` result, so every template + * vanished behind "Couldn't load resources". + * + * A hand-rolled JSON-RPC server is used rather than a composable test server + * because the SDK's own server cannot emit these shapes: they are exactly what + * its types forbid. The transport, the SDK client, and the salvage path are all + * real. + */ + +interface ListPage { + items: unknown[]; + nextCursor?: string; +} + +function jsonRpcResult(id: unknown, result: unknown) { + return { jsonrpc: "2.0", id, result }; +} + +/** + * Minimal streamable-HTTP MCP server that answers `initialize` and the list + * methods it was configured with, returning entries verbatim (malformed ones + * included). + */ +function startMalformedServer(initialPages: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; +}): Promise<{ + url: string; + stop: () => Promise<void>; + calls: string[]; + /** Swap what the server serves next, to model a server that got fixed. */ + setPages: (next: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; + }) => void; +}> { + const calls: string[] = []; + let pages = initialPages; + + const pageFor = (key: "resourceTemplates" | "tools", cursor?: string) => { + const configured = pages[key] ?? []; + const index = cursor === undefined ? 0 : Number(cursor); + return configured[index] ?? { items: [] }; + }; + + const handler = async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString(); + // The SDK also issues bodyless requests (the SSE GET, the session DELETE on + // teardown). Answer them without pretending they're JSON-RPC. + if (raw.length === 0) { + res.writeHead(405).end(); + return; + } + const body = JSON.parse(raw) as { + id?: unknown; + method: string; + params?: { cursor?: string }; + }; + calls.push(body.method); + + // Notifications carry no id and get an empty 202. + if (body.id === undefined) { + res.writeHead(202).end(); + return; + } + + const send = (result: unknown) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(jsonRpcResult(body.id, result))); + }; + + if (body.method === "initialize") { + send({ + protocolVersion: "2025-06-18", + capabilities: { resources: {}, tools: {} }, + serverInfo: { name: "malformed-list-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "resources/templates/list") { + const page = pageFor("resourceTemplates", body.params?.cursor); + send({ + resourceTemplates: page.items, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }); + return; + } + if (body.method === "tools/list") { + const page = pageFor("tools", body.params?.cursor); + send({ + tools: page.items, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }); + return; + } + send({}); + }; + + const server: Server = createServer((req, res) => { + void handler(req, res); + }); + + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + /* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */ + const port = typeof address === "object" && address ? address.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + calls, + setPages: (next) => { + pages = next; + }, + stop: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +const VALID_TEMPLATE = { + name: "full_annotations", + uriTemplate: "annotated://full/{id}", + annotations: { audience: ["user"], priority: 0.8 }, +}; +// The #1909 shape: `[]` where the spec says object. +const PHP_EMPTY_ANNOTATIONS = { + name: "array_annotations", + uriTemplate: "annotated://array/{id}", + annotations: [], +}; +const EMPTY_OBJECT_ANNOTATIONS = { + name: "empty_annotations", + uriTemplate: "annotated://empty/{id}", + annotations: {}, +}; + +describe("InspectorClient list salvage (#1909)", () => { + let client: InspectorClient | null = null; + let stopServer: (() => Promise<void>) | null = null; + + async function connectTo(url: string) { + client = new InspectorClient( + { type: "streamable-http", url }, + { environment: { transport: createTransportNode } }, + ); + await client.connect(); + return client; + } + + beforeEach(() => { + client = null; + stopServer = null; + }); + + afterEach(async () => { + try { + await client?.disconnect(); + } catch { + // Teardown only — the assertions already ran. + } + client = null; + await stopServer?.(); + stopServer = null; + }); + + it("keeps the valid templates and reports the malformed one", async () => { + const server = await startMalformedServer({ + resourceTemplates: [ + { + items: [ + EMPTY_OBJECT_ANNOTATIONS, + PHP_EMPTY_ANNOTATIONS, + VALID_TEMPLATE, + ], + }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { resourceTemplates } = await connected.listAllResourceTemplates(); + + // Before the fix this threw, and the panel showed zero templates. + expect(resourceTemplates.map((t) => t.name)).toEqual([ + "empty_annotations", + "full_annotations", + ]); + expect(connected.getMalformedListItems()).toEqual([ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: expect.stringMatching(/^annotations: /), + }, + ]); + }); + + it("accepts an empty annotations object without salvaging at all", async () => { + // `{}` is legal — every Annotations field is optional — so this must take + // the strict path and report nothing dropped. + const server = await startMalformedServer({ + resourceTemplates: [{ items: [EMPTY_OBJECT_ANNOTATIONS] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { resourceTemplates } = await connected.listAllResourceTemplates(); + expect(resourceTemplates).toHaveLength(1); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("emits malformedListItemsChange so the UI can warn", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const seen: unknown[] = []; + connected.addEventListener("malformedListItemsChange", (event) => { + seen.push(event.detail); + }); + await connected.listAllResourceTemplates(); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject([{ label: "array_annotations" }]); + }); + + it("marks the Protocol entry rejected even though the list rendered", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + const log = new MessageLogState(connected); + + await connected.listAllResourceTemplates(); + + const rejected = log + .getMessages() + .filter((entry) => entry.clientError !== undefined); + expect(rejected.length).toBeGreaterThan(0); + expect(rejected.at(-1)?.clientError).toContain("Dropped 1 malformed entry"); + log.destroy(); + }); + + it("salvages across pages, indexing against the aggregate", async () => { + const server = await startMalformedServer({ + tools: [ + { + items: [{ name: "ok_one", inputSchema: { type: "object" } }], + nextCursor: "1", + }, + { items: [{ name: "broken", inputSchema: "not-a-schema" }] }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["ok_one"]); + expect(connected.getMalformedListItems()).toMatchObject([ + { method: "tools/list", index: 1, label: "broken" }, + ]); + }); + + it("clears a previous report once the server answers cleanly", async () => { + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await connected.listAllResourceTemplates(); + expect(connected.getMalformedListItems()).toHaveLength(1); + + // The server is fixed; the next refresh must clear the stale report rather + // than leaving a warning about entries that are no longer wrong. + server.setPages({ resourceTemplates: [{ items: [VALID_TEMPLATE] }] }); + await connected.listAllResourceTemplates({ cacheMode: "bypass" }); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("rethrows when the rejection is not about any single entry", async () => { + // Every entry validates; the result is bad for another reason (a cursor of + // the wrong type). There is no per-item story, so the original error must + // survive rather than being swallowed into a partial list. + const server = await startMalformedServer({ + resourceTemplates: [ + { items: [VALID_TEMPLATE], nextCursor: 42 as unknown as string }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + expect(connected.getMalformedListItems()).toEqual([]); + }); +}); diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts index 00e3ed16..728ef5f7 100644 --- a/core/mcp/__tests__/fakeInspectorClient.ts +++ b/core/mcp/__tests__/fakeInspectorClient.ts @@ -38,6 +38,7 @@ import type { ExcludedTool, } from "../types.js"; import { INACTIVE_SUBSCRIPTION_STREAM_STATE } from "../types.js"; +import type { MalformedListItem } from "../listSalvage.js"; import type { JsonValue } from "../../json/jsonUtils.js"; type ListResult<TKey extends string, TItem> = { @@ -282,6 +283,18 @@ export class FakeInspectorClient this.dispatchTypedEvent("excludedToolsChange", excluded); } + private malformedListItems: MalformedListItem[] = []; + + getMalformedListItems(): MalformedListItem[] { + return this.malformedListItems; + } + + /** Test helper: set the malformed-entry set and emit the change (#1909). */ + setMalformedListItems(malformed: MalformedListItem[]): void { + this.malformedListItems = malformed; + this.dispatchTypedEvent("malformedListItemsChange", malformed); + } + getDiscoverResult(): DiscoverResult | undefined { return this.discoverResult; } diff --git a/core/mcp/index.ts b/core/mcp/index.ts index f8e37cd3..e83f9a5c 100644 --- a/core/mcp/index.ts +++ b/core/mcp/index.ts @@ -42,6 +42,9 @@ export type { ToolCallInvocation, } from "./types.js"; +// Per-item list salvage (#1909) +export type { MalformedListItem } from "./listSalvage.js"; + // Re-export JSON utilities export type { JsonValue } from "../json/jsonUtils.js"; export { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..e37994d5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -159,10 +159,26 @@ import { ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListPromptsResultSchema, + // Per-primitive item schemas — the salvage fallback validates a list's + // entries one at a time with these when the whole-result parse rejects + // (#1909, see `listAllSalvaging`). + ToolSchema, + ResourceSchema, + ResourceTemplateSchema, + PromptSchema, } from "@modelcontextprotocol/core"; import type { ClientResult } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; +import { + LenientListPageSchema, + isClientDecodeRejection, + rawItemsOf, + salvageListItems, + summarizeMalformed, + type MalformedListItem, +} from "./listSalvage.js"; import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; import { type JsonValue, @@ -407,6 +423,11 @@ export class InspectorClient extends InspectorClientEventTarget { // annotations (SEP-2243), recomputed on every aggregate tools refresh and // surfaced so the Tools tab can show why a tool vanished (#1632). private excludedTools: ExcludedTool[] = []; + // Entries the Inspector dropped from a list result because they failed the + // spec schema for that primitive, keyed by list method. Populated only by the + // salvage fallback in `listAllSalvaging` — empty against a conforming server + // (#1909). + private malformedListItems = new Map<string, MalformedListItem[]>(); // The capabilities this Inspector client advertises to the server during the // initialize handshake. Built once in setupClient() and snapshotted here so // UI surfaces (Server Info modal) can display them without poking at the @@ -2211,6 +2232,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.protocolEra = undefined; this.discoverResult = undefined; this.excludedTools = []; + this.malformedListItems.clear(); // Read as "not opted in" while disconnected. This is no longer what stops // it leaking into the next connection — `resetSessionState()` re-derives it // at connect, so removing this would leak nothing (#1629). Note the web @@ -2230,6 +2252,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("protocolEraChange", this.protocolEra); this.dispatchTypedEvent("discoverResultChange", this.discoverResult); this.dispatchTypedEvent("excludedToolsChange", this.excludedTools); + this.dispatchTypedEvent("malformedListItemsChange", []); } /** @@ -3126,12 +3149,20 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listTools( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); + const tools = await this.listAllSalvaging({ + method: "tools/list", + itemsKey: "tools", + itemSchema: ToolSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listTools( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).tools, + ], + }); // Recompute the SEP-2243 excluded-tools set alongside the aggregate. The // SDK already filtered `response.tools`, so it can't tell us what it // dropped — {@link refreshExcludedTools} re-lists the RAW `tools/list` to @@ -3153,7 +3184,7 @@ export class InspectorClient extends InspectorClientEventTarget { "Excluded-tools walk failed; the SEP-2243 excluded list may be incomplete", ); }); - return { tools: [...response.tools] }; + return { tools }; } /** @@ -3187,6 +3218,146 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("responseRejected", { id, reason }); } + /** Every entry dropped from a list result as malformed, across methods (#1909). */ + getMalformedListItems(): MalformedListItem[] { + return [...this.malformedListItems.values()].flat(); + } + + /** + * Record (or clear) the malformed entries for one list method and notify. + * + * A no-op when the method had none and still has none, so the common case — + * every conforming refresh, of which there are many — costs no event and no + * re-render. + */ + private setMalformedListItems( + method: string, + malformed: MalformedListItem[], + ): void { + const previous = this.malformedListItems.get(method) ?? []; + if (previous.length === 0 && malformed.length === 0) return; + if (malformed.length === 0) this.malformedListItems.delete(method); + else this.malformedListItems.set(method, malformed); + this.dispatchTypedEvent( + "malformedListItemsChange", + this.getMalformedListItems(), + ); + } + + /** + * Run a list aggregate, falling back to a per-item salvage when the SDK + * rejects the result it received (#1909). + * + * The strict aggregate runs first and unchanged, so a conforming server pays + * nothing: no extra request, no extra parse, and the SDK's response cache + * still serves it. Only an `InvalidResult` rejection — a response in hand + * that failed validation — reaches {@link salvageList}; every other failure + * (transport drop, timeout, server-sent error) rethrows untouched, because + * re-listing would not produce anything to salvage. + */ + private async listAllSalvaging<T>({ + method, + itemsKey, + itemSchema, + aggregate, + metadata, + }: { + method: string; + itemsKey: string; + itemSchema: z.ZodType<T>; + aggregate: () => Promise<T[]>; + metadata?: Record<string, string>; + }): Promise<T[]> { + try { + const items = await this.invokeMcpClient(aggregate, { method }); + this.setMalformedListItems(method, []); + return items; + } catch (err) { + if (!isClientDecodeRejection(err)) throw err; + return this.salvageList({ method, itemsKey, itemSchema, metadata, err }); + } + } + + /** + * Re-walk a list method with pagination validated but entries left raw, then + * validate each entry on its own — keeping the good ones and reporting the + * rest (#1909). + * + * Two deliberate properties: + * + * - **The original error wins when nothing is salvageable.** If every entry + * validates here, the strict rejection was about something else (a + * top-level field, an era codec decision), so there is no per-item story to + * tell and swallowing it would hide a real failure. Rethrow the original. + * - **A salvaged response is still a rejected response.** The Protocol entry + * is marked (#1953) with what was dropped, so the wire truth survives even + * though the list now renders. + * + * This walk is deliberately un-cached (it uses the raw per-page path), which + * is fine: it only runs against a server that just returned a non-conforming + * result. + */ + private async salvageList<T>({ + method, + itemsKey, + itemSchema, + metadata, + err, + }: { + method: string; + itemsKey: string; + itemSchema: z.ZodType<T>; + metadata?: Record<string, string>; + err: unknown; + }): Promise<T[]> { + const valid: T[] = []; + const malformed: MalformedListItem[] = []; + const seenCursors = new Set<string>(); + let cursor: string | undefined; + do { + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + ...(cursor ? { cursor } : {}), + }; + const page = await this.invokeMcpClient( + () => + this.client!.request( + { method, params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ), + { method }, + ); + const salvaged = salvageListItems({ + method, + items: rawItemsOf(page, itemsKey), + schema: itemSchema, + startIndex: valid.length + malformed.length, + }); + valid.push(...salvaged.valid); + malformed.push(...salvaged.malformed); + cursor = page.nextCursor; + if (cursor !== undefined) { + /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + } while (cursor !== undefined); + + // Nothing per-item was wrong, so the strict rejection was about something + // this fallback cannot explain — surface it rather than hide it. + if (malformed.length === 0) throw err; + + this.markResponseRejected(method, summarizeMalformed(malformed)); + this.setMalformedListItems(method, malformed); + this.logger.warn( + { method, malformed }, + "Dropped malformed entries from a list result; the rest of the list was kept", + ); + return valid; + } + /** The current SEP-2243 excluded-tools set (empty on legacy/stdio). */ getExcludedTools(): ExcludedTool[] { return this.excludedTools; @@ -4347,13 +4518,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listResources( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); - return { resources: [...response.resources] }; + const resources = await this.listAllSalvaging({ + method: "resources/list", + itemsKey: "resources", + itemSchema: ResourceSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listResources( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).resources, + ], + }); + return { resources }; } /** @@ -4504,15 +4683,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient( - () => - this.client!.listResourceTemplates( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - { method: "resources/templates/list" }, - ); - return { resourceTemplates: [...response.resourceTemplates] }; + const resourceTemplates = await this.listAllSalvaging({ + method: "resources/templates/list", + itemsKey: "resourceTemplates", + itemSchema: ResourceTemplateSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listResourceTemplates( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).resourceTemplates, + ], + }); + return { resourceTemplates }; } /** @@ -4559,13 +4744,21 @@ export class InspectorClient extends InspectorClientEventTarget { if (!this.client) { throw new Error("Client is not connected"); } - const response = await this.invokeMcpClient(() => - this.client!.listPrompts( - this.aggregateListParams(options?.metadata), - this.getCacheableRequestOptions(options?.cacheMode), - ), - ); - return { prompts: [...response.prompts] }; + const prompts = await this.listAllSalvaging({ + method: "prompts/list", + itemsKey: "prompts", + itemSchema: PromptSchema, + metadata: options?.metadata, + aggregate: async () => [ + ...( + await this.client!.listPrompts( + this.aggregateListParams(options?.metadata), + this.getCacheableRequestOptions(options?.cacheMode), + ) + ).prompts, + ], + }); + return { prompts }; } /** diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index d4edee16..572909c8 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -26,6 +26,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, } from "./types.js"; +import type { MalformedListItem } from "./listSalvage.js"; import type { Tool, ServerCapabilities, @@ -62,6 +63,12 @@ export interface InspectorClientEventMap { * connections (which don't exclude) and before connect (#1632). */ excludedToolsChange: ExcludedTool[]; + /** + * Entries dropped from a list result because they failed the spec schema for + * their primitive, across every list method. Empty against a conforming + * server; a non-empty set means the list rendered without them (#1909). + */ + malformedListItemsChange: MalformedListItem[]; capabilitiesChange: ServerCapabilities | undefined; serverInfoChange: Implementation | undefined; instructionsChange: string | undefined; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 6a5322ea..756b560f 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -37,6 +37,7 @@ import type { Tool, } from "@modelcontextprotocol/client"; import type { JsonValue } from "../json/jsonUtils.js"; +import type { MalformedListItem } from "./listSalvage.js"; import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js"; import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; @@ -65,6 +66,9 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { /** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations * (SEP-2243); empty on legacy/stdio connections (#1632). */ getExcludedTools(): ExcludedTool[]; + /** Entries dropped from a list result as malformed, across every list method; + * empty against a conforming server (#1909). */ + getMalformedListItems(): MalformedListItem[]; getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState; getDiscoverResult(): DiscoverResult | undefined; getServerSettings(): InspectorServerSettings | undefined; diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts new file mode 100644 index 00000000..a144c2bd --- /dev/null +++ b/core/mcp/listSalvage.ts @@ -0,0 +1,199 @@ +/** + * Per-item salvage for paginated list results (#1909). + * + * The SDK validates a list result as a whole: one non-conforming element + * rejects the entire response, so a server with a single malformed entry + * appears to have NO tools/resources/templates/prompts at all. That is exactly + * backwards for a debugging tool — the one thing the user needs to see is which + * entry is wrong and why, and the other entries are still perfectly usable. + * + * A real report: a PHP server whose empty `annotations` object reaches the wire + * as `[]` (`json_encode` of an empty array, or of `(array) new stdClass()`) + * instead of `{}` made every resource template vanish behind "Couldn't load + * resources" (#1909). + * + * The strict path is left alone. `InspectorClient.listAll*` still calls the + * SDK's cache-aware aggregate first, and only falls back to a lenient re-walk + * when that rejects a result it received — so a conforming server pays nothing, + * and the fallback is a pure recovery path. + * + * Salvaging does NOT mean accepting: a salvaged entry is dropped from the list + * and reported as {@link MalformedListItem}, and the Protocol entry is still + * marked rejected. The Inspector keeps telling the truth about the response; it + * just stops throwing away the valid part of it. + */ + +import { z } from "zod/v4"; +import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; + +/** + * One entry the Inspector dropped from a list result because it failed the + * spec's schema for that primitive. + */ +export interface MalformedListItem { + /** The list method that carried it, e.g. `"resources/templates/list"`. */ + method: string; + /** + * Zero-based position in the aggregated list. An entry too broken to carry a + * name is still identifiable by where it sat in the response. + */ + index: number; + /** Best-effort label read off the raw entry, when it has a usable one. */ + label?: string; + /** Why it was rejected — the first validation issue, with its field path. */ + reason: string; +} + +/** + * Whether a failed fetch is the CLIENT refusing a response it received, rather + * than the request never producing one. + * + * This gates both `markResponseRejected` (#1953) and the salvage fallback + * (#1909), and the distinction is load-bearing for each. For the Protocol-entry + * marking: the correlation recovers the request id as "the last response + * received for this method", which is only the failing exchange when a response + * actually just arrived and was refused while decoding. A transport drop, a + * timeout, or an aborted request produces no response frame at all — the + * last-answered id then still points at some EARLIER, successful call, and + * marking it would stamp "Rejected by the Inspector" onto an exchange that + * succeeded. For the salvage fallback: re-walking the list only makes sense + * when there IS a result to salvage; re-issuing the request after a timeout + * would just double the wait before failing anyway. + * + * A server-sent JSON-RPC error is excluded for a different reason: it is a real + * response, so the id would be right, but the failure is the server's. Its + * entry already renders as an error from the error frame itself, and blaming + * the Inspector for it would misattribute the cause. + * + * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed + * validation for the negotiated era"; `UnsupportedResultType` is its sibling + * for a `resultType` the codec has no handling for. Both are decisions the + * client made about a frame in hand. + */ +export function isClientDecodeRejection(err: unknown): boolean { + return ( + SdkError.isInstance(err) && + (err.code === SdkErrorCode.InvalidResult || + err.code === SdkErrorCode.UnsupportedResultType) + ); +} + +/** + * A list page parsed with everything except pagination left unvalidated. + * + * `looseObject` keeps the unknown keys, so the primitive array (`tools`, + * `resources`, `resourceTemplates`, `prompts`) survives as `unknown` for + * per-item validation. Only `nextCursor` is typed, because the walk itself + * depends on it — if THAT is malformed the page is unusable and the strict + * error should stand. + */ +export const LenientListPageSchema = z.looseObject({ + nextCursor: z.string().optional(), +}); + +export type LenientListPage = z.infer<typeof LenientListPageSchema>; + +/** Read the primitive array out of a leniently-parsed page. */ +export function rawItemsOf(page: LenientListPage, itemsKey: string): unknown[] { + const value = (page as Record<string, unknown>)[itemsKey]; + return Array.isArray(value) ? value : []; +} + +/** + * Best-effort human label for a raw entry: whichever identifying string field + * it happens to carry. Undefined when the entry is too broken to have one (a + * bare string, a number, a null) — the caller falls back to the index. + */ +export function labelForRawItem(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const record = value as Record<string, unknown>; + for (const key of ["name", "uriTemplate", "uri", "title"]) { + const candidate = record[key]; + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + return undefined; +} + +/** + * One-line summary of why an entry failed: the first issue, prefixed with its + * field path so `annotations` is named rather than left for the reader to find + * in a wall of JSON. The path is relative to the entry, not the response, since + * the entry is what we're reporting on. + */ +export function describeIssues(error: z.ZodError): string { + const [issue] = error.issues; + /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ + if (!issue) return "did not match the expected shape"; + const path = issue.path.join("."); + const detail = expectedDetail(issue); + const message = detail ? `${issue.message} (${detail})` : issue.message; + return path.length > 0 ? `${path}: ${message}` : message; +} + +/** + * `expected object` — appended because the issue's own message cannot be relied + * on to carry it. zod's browser build reports a bare "Invalid input" where Node + * says "Invalid input: expected object, received array", and the expected type + * is the whole diagnostic: without it the warning says something is wrong but + * not what would be right. Skipped when the message already spells it out, so + * the Node text doesn't end up saying it twice. + */ +function expectedDetail(issue: z.core.$ZodIssue): string | undefined { + if (!("expected" in issue) || typeof issue.expected !== "string") { + return undefined; + } + const detail = `expected ${issue.expected}`; + return issue.message.includes(detail) ? undefined : detail; +} + +/** + * Validate a page's entries one at a time, keeping the ones that pass. + * + * `startIndex` is the running count across pages, so an index in the report + * refers to the aggregated list the user sees rather than resetting per page. + */ +export function salvageListItems<T>({ + method, + items, + schema, + startIndex = 0, +}: { + method: string; + items: unknown[]; + schema: z.ZodType<T>; + startIndex?: number; +}): { valid: T[]; malformed: MalformedListItem[] } { + const valid: T[] = []; + const malformed: MalformedListItem[] = []; + items.forEach((item, offset) => { + const parsed = schema.safeParse(item); + if (parsed.success) { + valid.push(parsed.data); + return; + } + const label = labelForRawItem(item); + malformed.push({ + method, + index: startIndex + offset, + ...(label !== undefined && { label }), + reason: describeIssues(parsed.error), + }); + }); + return { valid, malformed }; +} + +/** + * Summary line used for the Protocol entry's rejection reason, so the Network / + * Protocol view still says the response was non-conforming even though the list + * rendered (#1953 + #1909). + */ +export function summarizeMalformed(malformed: MalformedListItem[]): string { + const [first] = malformed; + /* v8 ignore next -- callers only summarize a non-empty set */ + if (!first) return ""; + const where = first.label ?? `index ${first.index}`; + const rest = malformed.length > 1 ? ` (+${malformed.length - 1} more)` : ""; + return `Dropped ${malformed.length} malformed ${ + malformed.length === 1 ? "entry" : "entries" + } — ${where}: ${first.reason}${rest}`; +} diff --git a/core/mcp/state/managedListState.ts b/core/mcp/state/managedListState.ts index ea5128b5..eaf51f5d 100644 --- a/core/mcp/state/managedListState.ts +++ b/core/mcp/state/managedListState.ts @@ -17,7 +17,7 @@ import type { CacheMode, ServerCapabilities, } from "@modelcontextprotocol/client"; -import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { isClientDecodeRejection } from "../listSalvage.js"; import { isTerminalStatus } from "../types.js"; import { TypedEventTarget } from "../typedEventTarget.js"; @@ -30,38 +30,6 @@ import { TypedEventTarget } from "../typedEventTarget.js"; */ export const DEFAULT_LIST_CHANGED_DEBOUNCE_MS = 250; -/** - * Whether a failed fetch is the CLIENT refusing a response it received, rather - * than the request never producing one. - * - * This gates `markResponseRejected`, and the distinction is load-bearing. That - * correlation recovers the request id as "the last response received for this - * method", which is only the failing exchange when a response actually just - * arrived and was refused while decoding. A transport drop, a timeout, or an - * aborted request produces no response frame at all — the last-answered id then - * still points at some EARLIER, successful call, and marking it would stamp - * "Rejected by the Inspector" onto an exchange that succeeded. That is the very - * class of lie this issue exists to remove, so it must not be traded for - * another one. - * - * A server-sent JSON-RPC error is excluded for a different reason: it is a real - * response, so the id would be right, but the failure is the server's. Its - * entry already renders as an error from the error frame itself, and blaming - * the Inspector for it would misattribute the cause. - * - * `SdkErrorCode.InvalidResult` is exactly "a result arrived and failed - * validation for the negotiated era"; `UnsupportedResultType` is its sibling - * for a `resultType` the codec has no handling for. Both are decisions the - * client made about a frame in hand. - */ -function isClientDecodeRejection(err: unknown): boolean { - return ( - SdkError.isInstance(err) && - (err.code === SdkErrorCode.InvalidResult || - err.code === SdkErrorCode.UnsupportedResultType) - ); -} - /** * Every managed-list event map carries the list-changed indicator event and the * last-fetch error (#1953). diff --git a/core/react/useInspectorClient.ts b/core/react/useInspectorClient.ts index db0910c3..5e9de64a 100644 --- a/core/react/useInspectorClient.ts +++ b/core/react/useInspectorClient.ts @@ -11,6 +11,7 @@ import type { DiscoverResult, } from "@modelcontextprotocol/client"; import type { ExcludedTool } from "../mcp/types.js"; +import type { MalformedListItem } from "../mcp/listSalvage.js"; // Module-scope frozen object so the `?? EMPTY_CLIENT_CAPABILITIES` // fallback below doesn't return a fresh literal on every render — @@ -44,6 +45,12 @@ export interface UseInspectorClientResult { * connections and before connect (#1632). */ excludedTools: ExcludedTool[]; + /** + * Entries the Inspector dropped from a list result because they failed the + * spec schema for their primitive. Empty against a conforming server; a + * non-empty set means that list rendered without them (#1909). + */ + malformedListItems: MalformedListItem[]; /** * Message from the most recent mid-session transport failure (the client's * `error` event — stdio crash, SSE drop, HTTP 5xx). Stays set until the next @@ -97,6 +104,9 @@ export function useInspectorClient( const [excludedTools, setExcludedTools] = useState<ExcludedTool[]>( inspectorClient?.getExcludedTools() ?? [], ); + const [malformedListItems, setMalformedListItems] = useState< + MalformedListItem[] + >(inspectorClient?.getMalformedListItems() ?? []); const [lastError, setLastError] = useState<string | undefined>(undefined); useEffect(() => { @@ -109,6 +119,7 @@ export function useInspectorClient( setProtocolEra(undefined); setDiscoverResult(undefined); setExcludedTools([]); + setMalformedListItems([]); setLastError(undefined); return; } @@ -121,6 +132,7 @@ export function useInspectorClient( setProtocolEra(inspectorClient.getProtocolEra()); setDiscoverResult(inspectorClient.getDiscoverResult()); setExcludedTools(inspectorClient.getExcludedTools()); + setMalformedListItems(inspectorClient.getMalformedListItems()); setLastError(undefined); const onStatusChange = (event: TypedEvent<"statusChange">) => { @@ -156,6 +168,11 @@ export function useInspectorClient( ) => { setDiscoverResult(event.detail); }; + const onMalformedListItemsChange = ( + event: TypedEvent<"malformedListItemsChange">, + ) => { + setMalformedListItems(event.detail); + }; const onExcludedToolsChange = ( event: TypedEvent<"excludedToolsChange">, ) => { @@ -186,6 +203,10 @@ export function useInspectorClient( "excludedToolsChange", onExcludedToolsChange, ); + inspectorClient.addEventListener( + "malformedListItemsChange", + onMalformedListItemsChange, + ); return () => { inspectorClient.removeEventListener("statusChange", onStatusChange); @@ -218,6 +239,10 @@ export function useInspectorClient( "excludedToolsChange", onExcludedToolsChange, ); + inspectorClient.removeEventListener( + "malformedListItemsChange", + onMalformedListItemsChange, + ); }; }, [inspectorClient]); @@ -247,6 +272,7 @@ export function useInspectorClient( protocolEra, discoverResult, excludedTools, + malformedListItems, lastError, appRendererClient: inspectorClient?.getAppRendererClient() ?? null, connect, From 17a4c78dd3e787293539d619ed3c05cf28c9f251 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 16:59:37 -0400 Subject: [PATCH 127/941] fix: centralize the nullable-union collapse in core and apply it in the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2015, folded into #1928 rather than left as a follow-up. The TUI's `schemaToForm` has the same defect the web `SchemaForm` had: its local `JsonSchemaProperty` declares only `type?: string` — no `anyOf`, no array-valued `type` — so a `.nullish()` argument has no top-level `type` or `enum` for the dispatcher to read and falls to the `switch` default, losing its select. Milder than the web bug (a plain text field, not a corrupting JSON textarea), but the same class. Fixing it in a second place is the argument for not having the logic in a first place. So the collapse moves to `core/json/nullableUnion.ts` as `normalizeNullableUnion`, generic over a minimal `NullableUnionSchema` shape — the least a caller must expose to be *recognized* as a nullable union — so neither client has to adopt the other's schema type. Both form builders now share one copy and cannot drift on which schemas they render. - `core/json/nullableUnion.ts` — the shared collapse, plus tests at `clients/web/src/test/core/nullableUnion.test.ts` (the web suite is what gates `core/`). - `clients/web`: `schemaUtils.normalizeUnionType` is gone; `SchemaForm` and `resolveRefsInMessage` call the core function directly. - `clients/tui`: `schemaToForm` normalizes each property before dispatch, and `JsonSchemaProperty` grows the `anyOf` / array-`type` fields the encoding uses. Verified by driving the built TUI under a pty against the `nullable-fields-http.json` showcase server. Before, every argument of `record_shipment` rendered as a plain text field; after, `direction` is a select ("No value"), `quantity` an integer, `express` a boolean ("[Not set]"). Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 10 +- README.md | 5 +- clients/tui/__tests__/schemaToForm.test.ts | 113 ++++++++ clients/tui/src/utils/schemaToForm.ts | 23 +- .../groups/SchemaForm/SchemaForm.tsx | 4 +- .../web/src/test/core/nullableUnion.test.ts | 250 ++++++++++++++++++ clients/web/src/utils/schemaUtils.test.ts | 221 ---------------- clients/web/src/utils/schemaUtils.ts | 84 +----- core/json/nullableUnion.ts | 120 +++++++++ 9 files changed, 519 insertions(+), 311 deletions(-) create mode 100644 clients/web/src/test/core/nullableUnion.test.ts create mode 100644 core/json/nullableUnion.ts diff --git a/AGENTS.md b/AGENTS.md index 0baea459..87d51230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,15 @@ v2/main/ │ │ # annotation scan/validation + mirrored-param │ │ # derivation, used by the Tools tab — #1632; │ │ # plus `Mcp-Param-*` header building for the -│ │ # wire, used by both `tools/call` paths — #1846) +│ │ # wire, used by both `tools/call` paths — #1846; +│ │ # nullableUnion.ts: collapses the two JSON Schema +│ │ # encodings of "T or null" (`anyOf` with a null +│ │ # branch, `type: [T,"null"]`) into a plain type, +│ │ # hoisting the surviving branch's own keywords. +│ │ # Shared by BOTH form builders — web SchemaForm +│ │ # and TUI schemaToForm — since each dispatches on +│ │ # a single `type` string and would otherwise miss +│ │ # a nullable field entirely — #1928/#2015) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks diff --git a/README.md b/README.md index 82164a98..467a4fc0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ inspector/ ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) │ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends) │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets -│ ├── json/ # JSON + parameter/argument conversion utilities +│ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union +│ │ # schema collapse shared by the web and TUI form builders │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import │ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) @@ -234,6 +235,8 @@ The duplicated copies are appended rather than placed beside their twin on purpo Open the Tools tab and select `record_shipment`: `direction` must render as a **Select** (`envio` / `recebimento`) with a clear button that sets it back to `null`, `reference` as a text input, `quantity` as a number input, and `express` as a checkbox. On the broken build every one of them fell through to the raw-JSON textarea, which re-escaped its own contents on each keystroke until the value was unusable ([#1928](https://github.com/modelcontextprotocol/inspector/issues/1928)). The tool echoes the arguments it received, so the result panel shows exactly what was sent. +The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 7efca937..93ca1ca9 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -213,4 +213,117 @@ describe("schemaToForm", () => { ); expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "hi" }); }); + + // #2015 (the TUI twin of #1928): an argument declared with Zod's `.nullish()` + // has no top-level `type`/`enum` — they sit on the surviving `anyOf` branch — + // so before normalization every one of these degraded to a plain text field. + describe("nullable unions", () => { + it("renders a select for an anyOf string-enum|null argument", () => { + const form = schemaToForm( + { + properties: { + direction: { + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }, + }, + }, + "nullableEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "direction", + type: "select", + options: [ + { label: "envio", value: "envio" }, + { label: "recebimento", value: "recebimento" }, + ], + }); + }); + + it("maps the remaining nullable scalar branches to their typed fields", () => { + const form = schemaToForm( + { + properties: { + reference: { anyOf: [{ type: "string" }, { type: "null" }] }, + quantity: { + anyOf: [ + { type: "integer", minimum: 1, maximum: 9 }, + { type: "null" }, + ], + }, + ratio: { anyOf: [{ type: "number" }, { type: "null" }] }, + express: { anyOf: [{ type: "boolean" }, { type: "null" }] }, + }, + }, + "nullableScalars", + ); + const fields = form.sections[0]!.fields; + expect(fields.map((f) => ({ name: f.name, type: f.type }))).toEqual([ + { name: "reference", type: "string" }, + { name: "quantity", type: "integer" }, + { name: "ratio", type: "float" }, + { name: "express", type: "boolean" }, + ]); + // The branch's own constraints are hoisted along with its type. + expect(fields[1]).toMatchObject({ min: 1, max: 9 }); + }); + + it("renders a select for a nullable array-of-enum argument", () => { + const form = schemaToForm( + { + properties: { + tags: { + anyOf: [ + { type: "array", items: { enum: ["a", "b"] } }, + { type: "null" }, + ], + }, + }, + }, + "nullableArrayEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + options: [ + { label: "a", value: "a" }, + { label: "b", value: "b" }, + ], + }); + }); + + it("collapses the type: [T, null] encoding too", () => { + const form = schemaToForm( + { + properties: { + note: { type: ["string", "null"] }, + flag: { type: ["boolean", "null"] }, + }, + }, + "typeArrayNull", + ); + expect( + form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })), + ).toEqual([ + { name: "note", type: "string" }, + { name: "flag", type: "boolean" }, + ]); + }); + + it("leaves a union of two real types as a plain string field", () => { + const form = schemaToForm( + { + properties: { + mixed: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + }, + "realUnion", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "mixed", + type: "string", + }); + }); + }); }); diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index bbb730f2..5201abbb 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -3,10 +3,16 @@ */ import type { FormStructure, FormSection, FormField } from "ink-form"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; /** Minimal JSON Schema property shape used when building tool parameter forms */ interface JsonSchemaProperty { - type?: string; + /** + * An array here is the `["string", "null"]` nullable encoding, which + * {@link normalizeNullableUnion} collapses to a single name before the + * dispatch below reads it. + */ + type?: string | string[]; title?: string; enum?: unknown[]; /** Non-standard legacy support: titles for enum values */ @@ -15,6 +21,8 @@ interface JsonSchemaProperty { minimum?: number; maximum?: number; default?: unknown; + /** Present on a nullable union; see {@link normalizeNullableUnion}. */ + anyOf?: readonly unknown[]; } /** @@ -68,9 +76,16 @@ export function schemaToForm( // `properties` values are `unknown` (the SDK schema admits anything), so // guard before treating a value as a schema object — a malformed server // schema with e.g. `properties: { foo: null }` must not throw on `.title`. - const property = ( - typeof prop === "object" && prop !== null ? prop : {} - ) as JsonSchemaProperty; + // Flatten a nullable union (`anyOf: [X, {type:"null"}]`, `type: [X,"null"]`) + // before dispatching. Every branch below reads a single `type` string, and + // for an enum the `enum` keyword sits on the union's surviving branch — so + // without this an argument declared with Zod's `.nullish()` loses its + // select and degrades to a plain text field (#2015, the TUI twin of #1928). + const property = normalizeNullableUnion( + (typeof prop === "object" && prop !== null + ? prop + : {}) as JsonSchemaProperty, + ); const baseField = { name: key, label: property.title || key, diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index eadb0564..628f2ee3 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -12,7 +12,7 @@ import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; -import { normalizeUnionType } from "../../../utils/schemaUtils"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const FieldLabel = Text.withProps({ fw: 500, @@ -224,7 +224,7 @@ export function SchemaForm({ // without this an "optional and explicitly nullable" field — what Zod's // `.nullish()` emits — matches nothing and falls through to the raw-JSON // fallback, which is unusable for a value the user has to type (#1928). - const fieldSchema = normalizeUnionType(rawSchema); + const fieldSchema = normalizeNullableUnion(rawSchema); const isRequired = requiredFields.includes(fieldName); const label = fieldSchema.title ?? fieldName; const description = fieldSchema.description; diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts new file mode 100644 index 00000000..f56abbdd --- /dev/null +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from "vitest"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; + +// The two JSON Schema encodings of "this type, or null". Both form builders — +// the web `SchemaForm` and the TUI's `schemaToForm` — dispatch on a single +// `type` string, so this collapse is what keeps a nullable field renderable +// (#1928 in the web client, #2015 in the TUI). +describe("normalizeNullableUnion", () => { + it("normalizes anyOf string|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "string" }, { type: "null" }], + }), + ).toEqual({ + type: "string", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf boolean|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "boolean" }, { type: "null" }], + }), + ).toEqual({ + type: "boolean", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf number|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "number" }, { type: "null" }], + }), + ).toEqual({ + type: "number", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf integer|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "integer" }, { type: "null" }], + }), + ).toEqual({ + type: "integer", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf array|null", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "array" }, { type: "null" }], + }), + ).toEqual({ + type: "array", + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes type array string|null", () => { + expect(normalizeNullableUnion({ type: ["string", "null"] })).toEqual({ + type: "string", + nullable: true, + }); + }); + + it("normalizes type array boolean|null", () => { + expect(normalizeNullableUnion({ type: ["boolean", "null"] })).toEqual({ + type: "boolean", + nullable: true, + }); + }); + + it("normalizes type array number|null", () => { + expect(normalizeNullableUnion({ type: ["number", "null"] })).toEqual({ + type: "number", + nullable: true, + }); + }); + + it("normalizes type array integer|null", () => { + expect(normalizeNullableUnion({ type: ["integer", "null"] })).toEqual({ + type: "integer", + nullable: true, + }); + }); + + it("returns schema unchanged when no union pattern matches", () => { + const schema = { type: "string" as const }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores anyOf with more than two members", () => { + const schema = { + anyOf: [ + { type: "string" as const }, + { type: "null" as const }, + { type: "number" as const }, + ], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a two-member anyOf with no null branch", () => { + const schema = { + anyOf: [{ type: "string" as const }, { type: "number" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { + const schema = { + anyOf: [{ const: "only" }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // #1928: the enum lives on the surviving branch, and hoisting it is what makes + // the field render as a Select instead of the raw-JSON fallback. + it("hoists enum out of an anyOf string-enum|null branch", () => { + expect( + normalizeNullableUnion({ + description: "Direction", + anyOf: [ + { type: "string", enum: ["envio", "recebimento"] }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "string", + description: "Direction", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: true, + }); + }); + + it("infers string for a typeless enum branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ enum: ["a", "b"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a", "b"], + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists items out of an anyOf array|null branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [ + { type: "array", items: { type: "string", enum: ["a", "b"] } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "array", + items: { type: "string", enum: ["a", "b"] }, + anyOf: undefined, + nullable: true, + }); + }); + + it("hoists properties out of an anyOf object|null branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "null" }, + ], + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + anyOf: undefined, + nullable: true, + }); + }); + + it("normalizes anyOf object|null regardless of branch order", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "null" }, { type: "object" }], + }), + ).toEqual({ type: "object", anyOf: undefined, nullable: true }); + }); + + it("normalizes type array object|null", () => { + expect( + normalizeNullableUnion({ + type: ["object", "null"], + properties: { a: { type: "string" } }, + }), + ).toEqual({ + type: "object", + properties: { a: { type: "string" } }, + nullable: true, + }); + }); + + it("normalizes type array array|null", () => { + expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ + type: "array", + nullable: true, + }); + }); + + it("keeps a type array of two non-null members unchanged", () => { + const schema = { type: ["string", "number"] as const }; + expect( + normalizeNullableUnion({ ...schema, type: [...schema.type] }), + ).toEqual({ + type: ["string", "number"], + }); + }); + + // A server can put anything in `anyOf`; a non-object member must not be read + // as a branch (nor throw), it simply means no nullable union was recognized. + it("ignores an anyOf holding a non-object member", () => { + const schema = { anyOf: ["string", { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores an anyOf holding an array member", () => { + const schema = { anyOf: [["string"], { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a type array whose non-null member has no widget", () => { + const schema = { type: ["null", "null"] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("ignores a schema with no union keywords at all", () => { + const schema = { type: "object" as const, properties: {} }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); +}); diff --git a/clients/web/src/utils/schemaUtils.test.ts b/clients/web/src/utils/schemaUtils.test.ts index 59623fdb..7ff38c88 100644 --- a/clients/web/src/utils/schemaUtils.test.ts +++ b/clients/web/src/utils/schemaUtils.test.ts @@ -8,7 +8,6 @@ import { generateDefaultValue, isPropertyRequired, resolveRef, - normalizeUnionType, formatFieldLabel, resolveRefsInMessage, } from "./schemaUtils"; @@ -243,226 +242,6 @@ describe("resolveRef", () => { }); }); -describe("normalizeUnionType", () => { - it("normalizes anyOf string|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "string" }, { type: "null" }], - }), - ).toEqual({ - type: "string", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf boolean|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "boolean" }, { type: "null" }], - }), - ).toEqual({ - type: "boolean", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf number|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "number" }, { type: "null" }], - }), - ).toEqual({ - type: "number", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf integer|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "integer" }, { type: "null" }], - }), - ).toEqual({ - type: "integer", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf array|null", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "array" }, { type: "null" }], - }), - ).toEqual({ - type: "array", - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes type array string|null", () => { - expect(normalizeUnionType({ type: ["string", "null"] })).toEqual({ - type: "string", - nullable: true, - }); - }); - - it("normalizes type array boolean|null", () => { - expect(normalizeUnionType({ type: ["boolean", "null"] })).toEqual({ - type: "boolean", - nullable: true, - }); - }); - - it("normalizes type array number|null", () => { - expect(normalizeUnionType({ type: ["number", "null"] })).toEqual({ - type: "number", - nullable: true, - }); - }); - - it("normalizes type array integer|null", () => { - expect(normalizeUnionType({ type: ["integer", "null"] })).toEqual({ - type: "integer", - nullable: true, - }); - }); - - it("returns schema unchanged when no union pattern matches", () => { - const schema = { type: "string" as const }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores anyOf with more than two members", () => { - const schema = { - anyOf: [ - { type: "string" as const }, - { type: "null" as const }, - { type: "number" as const }, - ], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores a two-member anyOf with no null branch", () => { - const schema = { - anyOf: [{ type: "string" as const }, { type: "number" as const }], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - it("ignores an anyOf whose non-null branch has no renderable type (#1928)", () => { - const schema = { - anyOf: [{ const: "only" }, { type: "null" as const }], - }; - expect(normalizeUnionType(schema)).toBe(schema); - }); - - // #1928: the enum lives on the surviving branch, and hoisting it is what makes - // the field render as a Select instead of the raw-JSON fallback. - it("hoists enum out of an anyOf string-enum|null branch", () => { - expect( - normalizeUnionType({ - description: "Direction", - anyOf: [ - { type: "string", enum: ["envio", "recebimento"] }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "string", - description: "Direction", - enum: ["envio", "recebimento"], - anyOf: undefined, - nullable: true, - }); - }); - - it("infers string for a typeless enum branch", () => { - expect( - normalizeUnionType({ - anyOf: [{ enum: ["a", "b"] }, { type: "null" }], - }), - ).toEqual({ - type: "string", - enum: ["a", "b"], - anyOf: undefined, - nullable: true, - }); - }); - - it("hoists items out of an anyOf array|null branch", () => { - expect( - normalizeUnionType({ - anyOf: [ - { type: "array", items: { type: "string", enum: ["a", "b"] } }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "array", - items: { type: "string", enum: ["a", "b"] }, - anyOf: undefined, - nullable: true, - }); - }); - - it("hoists properties out of an anyOf object|null branch", () => { - expect( - normalizeUnionType({ - anyOf: [ - { type: "object", properties: { a: { type: "string" } } }, - { type: "null" }, - ], - }), - ).toEqual({ - type: "object", - properties: { a: { type: "string" } }, - anyOf: undefined, - nullable: true, - }); - }); - - it("normalizes anyOf object|null regardless of branch order", () => { - expect( - normalizeUnionType({ - anyOf: [{ type: "null" }, { type: "object" }], - }), - ).toEqual({ type: "object", anyOf: undefined, nullable: true }); - }); - - it("normalizes type array object|null", () => { - expect( - normalizeUnionType({ - type: ["object", "null"], - properties: { a: { type: "string" } }, - }), - ).toEqual({ - type: "object", - properties: { a: { type: "string" } }, - nullable: true, - }); - }); - - it("normalizes type array array|null", () => { - expect(normalizeUnionType({ type: ["array", "null"] })).toEqual({ - type: "array", - nullable: true, - }); - }); - - it("keeps a type array of two non-null members unchanged", () => { - const schema = { type: ["string", "number"] as const }; - expect(normalizeUnionType({ ...schema, type: [...schema.type] })).toEqual({ - type: ["string", "number"], - }); - }); -}); - describe("formatFieldLabel", () => { it("inserts spaces before capitals and capitalizes first letter", () => { expect(formatFieldLabel("firstName")).toBe("First Name"); diff --git a/clients/web/src/utils/schemaUtils.ts b/clients/web/src/utils/schemaUtils.ts index 2624fba1..b03582c0 100644 --- a/clients/web/src/utils/schemaUtils.ts +++ b/clients/web/src/utils/schemaUtils.ts @@ -3,6 +3,7 @@ import Ajv from "ajv"; import type { ValidateFunction } from "ajv"; import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/client"; import { isJSONRPCRequest } from "@modelcontextprotocol/client"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const ajv = new Ajv(); @@ -200,87 +201,6 @@ export function resolveRef( return schema; } -/** - * The `type` values {@link SchemaForm} dispatches on. `"null"` is deliberately - * absent: it is the branch a nullable union is being flattened *away* from, and - * there is no widget for a field whose only permitted value is `null`. - */ -const RENDERABLE_TYPES = [ - "string", - "number", - "integer", - "boolean", - "array", - "object", -] as const; - -type RenderableType = (typeof RENDERABLE_TYPES)[number]; - -function isRenderableType(type: unknown): type is RenderableType { - return RENDERABLE_TYPES.includes(type as RenderableType); -} - -/** - * Normalizes a nullable union (`string|null` from FastMCP, or anything Zod's - * `.nullish()` / `.nullable()` compiles to) into the plain type the form - * renderer dispatches on. - * - * Two encodings mean the same thing and are both flattened to - * `{ type: <T>, nullable: true }`: - * - * - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are - * hoisted onto the result, because that is where the detail the renderer - * needs lives. A nullable enum compiles to - * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting - * `enum` is what makes it a `Select` instead of falling through to the raw - * JSON fallback (#1928). The branch also wins over the wrapper on any shared - * key, matching v1.x's behavior. - * - `type: [<T>, "null"]` — the keywords already sit at the top level, so only - * `type` collapses. - * - * Anything else — a union of two real types, a three-member `anyOf`, a branch - * whose type has no widget — is returned untouched and renders through the - * JSON fallback, which is the honest representation of a shape the form cannot - * model. - * - * @param schema The JSON schema to normalize - * @returns A normalized schema, or the original schema when no nullable-union - * pattern matches - */ -export function normalizeUnionType( - schema: InspectorFormSchema, -): InspectorFormSchema { - if (schema.anyOf && schema.anyOf.length === 2) { - // `JsonSchemaConst` is structurally a subset of `InspectorFormSchema` (it - // just carries no `type`), so reading both as the latter is safe. - const branches = schema.anyOf as InspectorFormSchema[]; - const nullBranch = branches.find((branch) => branch?.type === "null"); - const branch = branches.find((candidate) => candidate?.type !== "null"); - - if (nullBranch && branch) { - // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows - // that, and every value such a schema admits here is a string. - const type = branch.type ?? (branch.enum ? "string" : undefined); - if (isRenderableType(type)) { - return { ...schema, ...branch, type, anyOf: undefined, nullable: true }; - } - } - } - - if ( - Array.isArray(schema.type) && - schema.type.length === 2 && - schema.type.includes("null") - ) { - const type = schema.type.find((member) => member !== "null"); - if (isRenderableType(type)) { - return { ...schema, type, nullable: true }; - } - } - - return schema; -} - /** * Formats a field key into a human-readable label * @param key The field key to format @@ -319,7 +239,7 @@ export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage { Object.entries(requestedSchema.properties).map( ([key, propSchema]) => { const resolved = resolveRef(propSchema, requestedSchema); - const normalized = normalizeUnionType(resolved); + const normalized = normalizeNullableUnion(resolved); return [key, normalized]; }, ), diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts new file mode 100644 index 00000000..4e81b791 --- /dev/null +++ b/core/json/nullableUnion.ts @@ -0,0 +1,120 @@ +/** + * Flattening of the two JSON Schema encodings of "this type, **or** null" into + * the plain type a form renderer can dispatch on. + * + * Shared because both form builders — the web client's `SchemaForm` and the + * TUI's `schemaToForm` — dispatch on a *single* `type` string, and so both miss + * a nullable field entirely without this step (#1928, #2015). Keeping one copy + * is what stops the two clients from disagreeing about which schemas they can + * render; the alternative was two implementations of the same subtle predicate. + */ + +/** + * JSON Schema type names a form renderer can build a widget for. + * + * `"null"` is deliberately absent: it is the branch a nullable union is being + * flattened *away* from, and there is no widget for a field whose only + * permitted value is `null`. + */ +const RENDERABLE_TYPES = [ + "string", + "number", + "integer", + "boolean", + "array", + "object", +] as const; + +type RenderableType = (typeof RENDERABLE_TYPES)[number]; + +function isRenderableType(type: unknown): type is RenderableType { + return RENDERABLE_TYPES.includes(type as RenderableType); +} + +/** + * The keywords {@link normalizeNullableUnion} reads. Both clients' own schema + * types are structurally assignable to this, so neither has to adopt the + * other's — the shape is deliberately the minimum needed to *recognize* a + * nullable union, not a full JSON Schema model. + */ +export interface NullableUnionSchema { + type?: string | string[]; + enum?: unknown[]; + anyOf?: readonly unknown[]; +} + +/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */ +function toBranch(value: unknown): Record<string, unknown> | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as Record<string, unknown>; +} + +/** + * Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and + * FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`. + * + * Two encodings mean the same thing and are both handled: + * + * - `anyOf: [<branch>, { type: "null" }]` — the branch's *own* keywords are + * hoisted onto the result, because that is where the detail a renderer needs + * lives. A nullable enum compiles to + * `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`, so hoisting + * `enum` is what makes it a dropdown rather than a raw-JSON fallback (#1928). + * The branch also wins over the wrapper on any shared key, matching v1.x. + * - `type: [<T>, "null"]` — the keywords already sit at the top level, so only + * `type` collapses. + * + * Anything else — a union of two real types, a three-member `anyOf`, a branch + * whose type has no widget — is returned **by identity**, so a caller can use + * `===` to tell that nothing was recognized. + * + * @param schema The schema to normalize + * @returns A flattened copy, or `schema` itself when no nullable-union pattern + * matches + */ +export function normalizeNullableUnion<T extends NullableUnionSchema>( + schema: T, +): T { + if (schema.anyOf?.length === 2) { + const branches = schema.anyOf.map(toBranch); + const nullBranch = branches.find((entry) => entry?.type === "null"); + const branch = branches.find( + (entry) => entry !== null && entry.type !== "null", + ); + + if (nullBranch && branch) { + // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows + // that, and every value such a schema admits here is a string. + const type = branch.type ?? (branch.enum ? "string" : undefined); + if (isRenderableType(type)) { + // The result is `schema` with `type` narrowed to a value it already + // admitted, the branch's keywords merged in, and `nullable` added. TS + // cannot express "a spread of T and a subset of T is still a T", and a + // non-generic return would force every caller to cast instead. + return { + ...schema, + ...branch, + type, + anyOf: undefined, + nullable: true, + } as T; + } + } + } + + if ( + Array.isArray(schema.type) && + schema.type.length === 2 && + schema.type.includes("null") + ) { + const type = schema.type.find((member) => member !== "null"); + if (isRenderableType(type)) { + // Same reasoning as above. + return { ...schema, type, nullable: true } as T; + } + } + + return schema; +} From e4e16bb3e6783a7ca08e9072e4c899cd33b6544c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 17:24:46 -0400 Subject: [PATCH 128/941] fix: address Copilot review on the nullable-union collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all valid: 1. Returning `T` was unsound. The collapse genuinely changes two fields — `type` stops being an array, `anyOf` becomes `undefined` — so a caller whose `T` typed either that way could keep treating it so afterwards. The result is now `T | NormalizedNullableUnion<T>`, which states exactly that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a cleared `anyOf`, and `nullable`. The one assertion left is isolated in a `collapsed()` helper, needed only because `Omit` stays deferred while `T` is open. 2. A typeless `enum` does not imply strings. JSON Schema allows `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now only an all-string enum earns the inference; anything else stays on the JSON fallback. An explicit `type` is still authoritative. 3. `hasMissingRequiredFields` counted every null as missing, so clearing a *required* nullable enum — newly possible via the clear button — would disable submit on a value the schema permits. `required` constrains presence, not content, so null now counts as present exactly when the field's schema admits it. 4. `collectSchemaDefaults` read the raw union, so a default nested inside a nullable object's branch was displayed by the form but never seeded into the submitted values. It normalizes each property first, like the renderer does. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 33 +++++++ clients/web/src/utils/jsonUtils.test.ts | 76 +++++++++++++++ clients/web/src/utils/jsonUtils.ts | 25 ++++- core/json/nullableUnion.ts | 93 +++++++++++++++---- 4 files changed, 207 insertions(+), 20 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index f56abbdd..915e7e8e 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -144,6 +144,39 @@ describe("normalizeNullableUnion", () => { }); }); + // JSON Schema's `enum` is untyped, so a typeless branch only implies strings + // when every member is one. Guessing otherwise would hand a number to a + // renderer that declared the option list `string[]`. + it("does not infer string for a typeless non-string enum branch", () => { + const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("does not infer string for a typeless mixed enum branch", () => { + const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("does not infer string for a typeless empty enum branch", () => { + const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // An explicit `type` is authoritative — the enum members are the server's + // problem at that point, not an inference this function is making. + it("still collapses a non-string enum branch that declares its type", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }], + }), + ).toEqual({ + type: "number", + enum: [1, 2], + anyOf: undefined, + nullable: true, + }); + }); + it("infers string for a typeless enum branch", () => { expect( normalizeNullableUnion({ diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 57a61132..4a69409c 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => { it("returns an empty object when the schema has no properties", () => { expect(collectSchemaDefaults({ type: "object" })).toEqual({}); }); + + // The form normalizes a nullable union before rendering, so this has to as + // well: a nested object's `properties` live on the union's surviving branch, + // and a default only visible there would be displayed but never submitted. + it("collects defaults hoisted out of a nullable object union", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + profile: { + anyOf: [ + { + type: "object", + properties: { nick: { type: "string", default: "ada" } }, + }, + { type: "null" }, + ], + }, + }, + }; + expect(collectSchemaDefaults(schema)).toEqual({ + profile: { nick: "ada" }, + }); + }); + + it("keeps a default declared on the union wrapper itself", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + direction: { + default: "envio", + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + }; + expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" }); + }); }); describe("hasMissingRequiredFields", () => { @@ -247,4 +283,44 @@ describe("hasMissingRequiredFields", () => { false, ); }); + + // JSON Schema `required` constrains presence, not content — so an explicit + // `null` satisfies a required field whose schema admits null. Since #1928 the + // user can produce exactly that by clearing a nullable enum, and treating it + // as missing would disable submit on a value the schema calls valid. + it("accepts an explicit null for a required field that admits null", () => { + const nullable: InspectorFormSchema = { + type: "object", + properties: { + direction: { + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }, + }, + required: ["direction"], + }; + expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false); + expect(hasMissingRequiredFields(nullable, {})).toBe(true); + }); + + it("accepts an explicit null for a required type: [T, null] field", () => { + const nullable: InspectorFormSchema = { + type: "object", + properties: { note: { type: ["string", "null"] } }, + required: ["note"], + }; + expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false); + }); + + it("still rejects null for a required field whose schema is not nullable", () => { + expect(hasMissingRequiredFields(schema, { name: null })).toBe(true); + }); + + it("rejects null for a required field the schema does not describe", () => { + const undescribed: InspectorFormSchema = { + type: "object", + properties: {}, + required: ["ghost"], + }; + expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true); + }); }); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 98dd905a..2cdb2331 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -1,3 +1,5 @@ +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; + export type JsonValue = | string | number @@ -116,7 +118,12 @@ export function collectSchemaDefaults( ): Record<string, unknown> { const properties = schema.properties ?? {}; const result: Record<string, unknown> = {}; - for (const [fieldName, fieldSchema] of Object.entries(properties)) { + for (const [fieldName, rawSchema] of Object.entries(properties)) { + // Collapse a nullable union first, for the same reason `SchemaForm` does: + // a nested object's `properties` live on the union's surviving branch, so + // without this the form would *display* a hoisted default that never + // reached the seeded values — the field would submit empty (#1928). + const fieldSchema = normalizeNullableUnion(rawSchema); if (fieldSchema.default !== undefined) { result[fieldName] = fieldSchema.default; } else if (fieldSchema.type === "object" && fieldSchema.properties) { @@ -133,15 +140,29 @@ export function collectSchemaDefaults( * Whether any of the schema's required top-level fields is missing a value in * `values` (absent, null, or empty string). Used to gate a form's submit * action until required fields are supplied. + * + * `null` counts as missing only for a field that does not admit it. JSON + * Schema's `required` constrains *presence*, not content, so a required field + * whose schema is a nullable union is satisfied by an explicit `null` — and + * since #1928 the user can produce exactly that by clearing a nullable enum. + * Treating it as missing would leave the form's submit permanently disabled on + * a value the schema calls valid. */ export function hasMissingRequiredFields( schema: InspectorFormSchema, values: Record<string, unknown>, ): boolean { const required = schema.required ?? []; + const properties = schema.properties ?? {}; return required.some((field) => { const value = values[field]; - return value === undefined || value === null || value === ""; + if (value === null) { + const fieldSchema = properties[field]; + return fieldSchema === undefined + ? true + : normalizeNullableUnion(fieldSchema).nullable !== true; + } + return value === undefined || value === ""; }); } diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 4e81b791..2adfdd51 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -25,7 +25,8 @@ const RENDERABLE_TYPES = [ "object", ] as const; -type RenderableType = (typeof RENDERABLE_TYPES)[number]; +/** A `type` name the collapse is willing to produce. */ +export type RenderableType = (typeof RENDERABLE_TYPES)[number]; function isRenderableType(type: unknown): type is RenderableType { return RENDERABLE_TYPES.includes(type as RenderableType); @@ -51,6 +52,71 @@ function toBranch(value: unknown): Record<string, unknown> | null { return value as Record<string, unknown>; } +/** + * Whether a branch's `enum` is one a *typeless* branch can be read as a string + * enum. + * + * JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are + * all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing + * `"string"` for a numeric enum would hand non-strings to a renderer that has + * declared them `string[]`: the web `Select` would receive numbers, and the TUI + * would `String(...)` them and submit `"1"` where the server expects `1`. So + * only an all-string enum earns the inference; anything else stays on the + * fallback path, which renders the value honestly as JSON. + */ +function isStringEnum(value: unknown): boolean { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((member) => typeof member === "string") + ); +} + +/** + * The result of a collapse that actually matched. Spelled out rather than + * reusing `T` because the collapse genuinely changes two of `T`'s fields: + * `type` becomes a single renderable name (never an array, never `"null"`), + * and `anyOf` is cleared. Returning `T` for these would let a caller whose `T` + * types `type` as an array go on treating it as one after it has become a + * string, or dereference an `anyOf` that is now `undefined`. + * + * Note this cannot make the *hoist* sound: an `anyOf` branch is `unknown`, so + * any keyword lifted off it is whatever the server sent, however `T` declares + * it. {@link isStringEnum} validates the one keyword the renderers dereference + * as a typed array (`enum`); the rest reach widgets that read them defensively. + */ +export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit< + T, + "type" | "anyOf" +> & { + type?: RenderableType; + anyOf?: undefined; + nullable?: boolean; +}; + +/** + * Build the collapsed schema. + * + * The assertion is needed because the spread merges an unresolved generic `T` + * with an `unknown` branch, and `NormalizedNullableUnion<T>`'s `Omit` stays + * deferred while `T` is open — so TS cannot verify the two line up even though + * every field is set right here. Isolated in this one function so the exported + * signature carries the contract and nothing else has to assert it. + */ +function collapsed<T extends NullableUnionSchema>( + schema: T, + branch: Record<string, unknown> | undefined, + type: RenderableType, +): NormalizedNullableUnion<T> { + return { + ...schema, + ...branch, + type, + anyOf: undefined, + nullable: true, + } as NormalizedNullableUnion<T>; +} + /** * Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and * FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`. @@ -76,7 +142,7 @@ function toBranch(value: unknown): Record<string, unknown> | null { */ export function normalizeNullableUnion<T extends NullableUnionSchema>( schema: T, -): T { +): T | NormalizedNullableUnion<T> { if (schema.anyOf?.length === 2) { const branches = schema.anyOf.map(toBranch); const nullBranch = branches.find((entry) => entry?.type === "null"); @@ -85,21 +151,13 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( ); if (nullBranch && branch) { - // A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows - // that, and every value such a schema admits here is a string. - const type = branch.type ?? (branch.enum ? "string" : undefined); + // A branch may carry an `enum` and no `type`; JSON Schema allows that, and + // an all-string enum is unambiguously a string field. See isStringEnum for + // why a non-string enum deliberately does not get the same treatment. + const type = + branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined); if (isRenderableType(type)) { - // The result is `schema` with `type` narrowed to a value it already - // admitted, the branch's keywords merged in, and `nullable` added. TS - // cannot express "a spread of T and a subset of T is still a T", and a - // non-generic return would force every caller to cast instead. - return { - ...schema, - ...branch, - type, - anyOf: undefined, - nullable: true, - } as T; + return collapsed(schema, branch, type); } } } @@ -111,8 +169,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { - // Same reasoning as above. - return { ...schema, type, nullable: true } as T; + return collapsed(schema, undefined, type); } } From 54c60881149affed72032c52ffa987439abdf098 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 17:30:25 -0400 Subject: [PATCH 129/941] fix: address Copilot review on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three real defects it caught, plus the tests it asked for: - The strict `tools/list` aggregate is filtered by the SDK for invalid `x-mcp-header` annotations (SEP-2243), but the salvage path was not: one ordinary malformed tool would trip the fallback and readmit every invalid-header tool beside it. Salvaged tools now go through the same scan. - `markResponseRejected` resolves "the last response received for this method", and the re-walk answers that same method — so the mark landed on a salvage page that succeeded while the actually-invalid response rendered clean. The correlation id is now captured before the re-walk. - The report was cleared only on an explicit `disconnect()`. The route out that tears down nothing (`onerror` with no `onclose`) carried it into the next connection, so it is now cleared on the start-clean `resetSessionState()` path as well. Fixing the first exposed two more, both found by the new test: - The modern (2026-07-28) codec validates a result against the spec schema before the caller's, so a lenient `client.request` is rejected exactly as the strict one was — there was no salvage on the modern leg at all. It now goes through the raw-wire channel that exists for the modern `tasks/*` methods. `withModernEnvelope` is split out of `withModernTaskEnvelope` so a list request doesn't claim the tasks extension the user may have disabled. - A malformed tool also blanked the SEP-2243 excluded set, leaving a tool that vanished for an invalid header with no explanation — #1909's own complaint one level down. The scan walk now tolerates a malformed sibling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../core/react/useInspectorClient.test.tsx | 70 ++++++++ .../inspectorClient-malformed-list.test.ts | 130 +++++++++++++- core/mcp/inspectorClient.ts | 164 ++++++++++++++++-- 3 files changed, 340 insertions(+), 24 deletions(-) diff --git a/clients/web/src/test/core/react/useInspectorClient.test.tsx b/clients/web/src/test/core/react/useInspectorClient.test.tsx index 56287702..f24e3692 100644 --- a/clients/web/src/test/core/react/useInspectorClient.test.tsx +++ b/clients/web/src/test/core/react/useInspectorClient.test.tsx @@ -144,6 +144,76 @@ describe("useInspectorClient", () => { expect(result.current.excludedTools).toEqual([]); }); + it("subscribes to malformedListItemsChange and updates (#1909)", () => { + const client = new FakeInspectorClient(); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.malformedListItems).toEqual([]); + const malformed = [ + { + method: "resources/templates/list", + index: 1, + label: "array_annotations", + reason: "annotations: Invalid input (expected object)", + }, + ]; + act(() => { + client.setMalformedListItems(malformed); + }); + expect(result.current.malformedListItems).toEqual(malformed); + }); + + it("seeds malformedListItems from the client it is given (#1909)", () => { + // A client that already salvaged before this hook mounted (a screen opened + // after the connect-time list load) must show the report immediately. + const client = new FakeInspectorClient({ status: "connected" }); + const malformed = [ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]; + client.setMalformedListItems(malformed); + const { result } = renderHook(() => useInspectorClient(client)); + expect(result.current.malformedListItems).toEqual(malformed); + }); + + it("resets malformedListItems to [] when client becomes null (#1909)", () => { + const client = new FakeInspectorClient({ status: "connected" }); + client.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), { + initialProps: { c: client as FakeInspectorClient | null }, + }); + expect(result.current.malformedListItems.length).toBe(1); + rerender({ c: null }); + expect(result.current.malformedListItems).toEqual([]); + }); + + it("re-seeds malformedListItems on a client swap, not the old server's (#1909)", () => { + const first = new FakeInspectorClient({ status: "connected" }); + first.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + const second = new FakeInspectorClient({ status: "connected" }); + const { result, rerender } = renderHook(({ c }) => useInspectorClient(c), { + initialProps: { c: first as FakeInspectorClient }, + }); + expect(result.current.malformedListItems).toHaveLength(1); + rerender({ c: second }); + expect(result.current.malformedListItems).toEqual([]); + }); + + it("stops listening for malformedListItemsChange after unmount (#1909)", () => { + const client = new FakeInspectorClient({ status: "connected" }); + const { result, unmount } = renderHook(() => useInspectorClient(client)); + unmount(); + act(() => { + client.setMalformedListItems([ + { method: "tools/list", index: 0, reason: "name: expected string" }, + ]); + }); + // A listener left attached would update state on an unmounted tree. + expect(result.current.malformedListItems).toEqual([]); + }); + it("resets protocolEra / discoverResult to defaults when client becomes null", () => { const client = new FakeInspectorClient({ status: "connected", diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index d864e9b1..db04bb13 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -8,6 +8,7 @@ import { import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { MessageLogState } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; /** * Per-item list salvage against a real connection (#1909). @@ -23,6 +24,8 @@ import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; * real. */ +const MODERN_VERSION = "2026-07-28"; + interface ListPage { items: unknown[]; nextCursor?: string; @@ -37,10 +40,14 @@ function jsonRpcResult(id: unknown, result: unknown) { * methods it was configured with, returning entries verbatim (malformed ones * included). */ -function startMalformedServer(initialPages: { - resourceTemplates?: ListPage[]; - tools?: ListPage[]; -}): Promise<{ +function startMalformedServer( + initialPages: { + resourceTemplates?: ListPage[]; + tools?: ListPage[]; + }, + /** Negotiate the modern era, which is what turns the SEP-2243 gate on. */ + modern = false, +): Promise<{ url: string; stop: () => Promise<void>; calls: string[]; @@ -82,14 +89,41 @@ function startMalformedServer(initialPages: { return; } - const send = (result: unknown) => { + const send = (result: Record<string, unknown>) => { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(jsonRpcResult(body.id, result))); + // The 2026-07-28 codec requires `resultType` on every result; the legacy + // leg ignores it. + res.end( + JSON.stringify( + jsonRpcResult( + body.id, + modern + ? { + resultType: "complete", + // The modern leg also requires the cache hints on a list + // result; `ttlMs: 0` means "don't cache", which keeps each + // call in these tests a real round trip. + ttlMs: 0, + cacheScope: "public", + ...result, + } + : result, + ), + ), + ); }; if (body.method === "initialize") { send({ - protocolVersion: "2025-06-18", + protocolVersion: modern ? MODERN_VERSION : "2025-06-18", + capabilities: { resources: {}, tools: {} }, + serverInfo: { name: "malformed-list-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "server/discover") { + send({ + supportedVersions: [MODERN_VERSION], capabilities: { resources: {}, tools: {} }, serverInfo: { name: "malformed-list-server", version: "1.0.0" }, }); @@ -159,10 +193,13 @@ describe("InspectorClient list salvage (#1909)", () => { let client: InspectorClient | null = null; let stopServer: (() => Promise<void>) | null = null; - async function connectTo(url: string) { + async function connectTo(url: string, era?: "legacy" | "modern") { client = new InspectorClient( { type: "streamable-http", url }, - { environment: { transport: createTransportNode } }, + { + environment: { transport: createTransportNode }, + ...(era ? { versionNegotiation: eraToVersionNegotiation(era) } : {}), + }, ); await client.connect(); return client; @@ -247,6 +284,37 @@ describe("InspectorClient list salvage (#1909)", () => { expect(seen[0]).toMatchObject([{ label: "array_annotations" }]); }); + it("marks the ORIGINAL rejected response, not a salvage page", async () => { + // The re-walk answers the same method again, so a mark taken afterwards + // would land on a page that succeeded and leave the invalid response + // rendering clean — the exact lie #1953 exists to remove. + const server = await startMalformedServer({ + resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + const log = new MessageLogState(connected); + + await connected.listAllResourceTemplates(); + + const templateCalls = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "resources/templates/list", + ); + // Two exchanges: the strict aggregate that was rejected, then the re-walk. + expect(templateCalls.length).toBeGreaterThanOrEqual(2); + expect(templateCalls[0]?.clientError).toContain( + "Dropped 1 malformed entry", + ); + for (const later of templateCalls.slice(1)) { + expect(later.clientError).toBeUndefined(); + } + log.destroy(); + }); + it("marks the Protocol entry rejected even though the list rendered", async () => { const server = await startMalformedServer({ resourceTemplates: [{ items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE] }], @@ -302,6 +370,50 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); + it("does not readmit a SEP-2243-excluded tool while salvaging", async () => { + // The strict aggregate is filtered by the SDK, so the salvage path has to + // reapply the rule: otherwise one ordinary malformed tool anywhere in the + // list quietly readmits every invalid-header tool beside it, turning a + // rendering fix into a spec violation (#1632 + #1909). + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "ok_tool", inputSchema: { type: "object" } }, + // Malformed: trips the salvage fallback. + { name: "broken", inputSchema: "not-a-schema" }, + // Schema-valid, but its x-mcp-header is not an RFC 9110 token. + { + name: "invalid_header_tool", + inputSchema: { + type: "object", + properties: { + value: { + type: "string", + "x-mcp-header": "Bad Header", + }, + }, + }, + }, + ], + }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + expect(connected.getProtocolEra()).toBe("modern"); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["ok_tool"]); + // Still reported as excluded rather than silently gone. + expect(connected.getExcludedTools().map((x) => x.tool.name)).toEqual([ + "invalid_header_tool", + ]); + }); + it("rethrows when the rejection is not about any single entry", async () => { // Every entry validates; the result is bad for another reason (a cursor of // the wrong type). There is no per-item story, so the original error must diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index e37994d5..785c0c35 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1616,6 +1616,15 @@ export class InspectorClient extends InspectorClientEventTarget { // (#1953). this.outboundRequestMethods.clear(); this.lastAnsweredRequestByMethod.clear(); + // Per-session for the same reason: a report names entries of the PREVIOUS + // server's list. Cleared here as well as in `disconnect()` because the + // route out that tears down nothing (`onerror` with no `onclose`) would + // otherwise carry it into the next connection — indefinitely, if that + // server never lists the method again (#1909). + if (this.malformedListItems.size > 0) { + this.malformedListItems.clear(); + this.dispatchTypedEvent("malformedListItemsChange", []); + } for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } @@ -2372,8 +2381,6 @@ export class InspectorClient extends InspectorClientEventTarget { private withModernTaskEnvelope( params: Record<string, unknown>, ): Record<string, unknown> { - const existingMeta = - (params._meta as Record<string, unknown> | undefined) ?? {}; const clientCapabilities = { ...this.clientCapabilities, // Force-stamp the tasks extension regardless of what the client @@ -2385,11 +2392,31 @@ export class InspectorClient extends InspectorClientEventTarget { [TASKS_EXTENSION_KEY]: {}, }, }; - // Use the NEGOTIATED protocol version so the envelope agrees with the - // `MCP-Protocol-Version` header the transport stamps from the same source — - // a future modern-family revision would negotiate a different string, and - // the two must not disagree. The raw channel only runs on a connected modern - // session, so this is always set; the constant is a defensive fallback. + return this.withModernEnvelope(params, clientCapabilities); + } + + /** + * Stamp the `_meta` envelope every raw-wire request needs on the modern leg: + * the negotiated protocol version, the client identity, and the client + * capabilities. + * + * Split out of {@link withModernTaskEnvelope} so the list-salvage re-walk + * (#1909) can reuse it WITHOUT that method's force-stamped tasks extension — + * a `tools/list` has no business claiming an extension the user may have + * deliberately turned off (#1738). + * + * The NEGOTIATED protocol version is used so the envelope agrees with the + * `MCP-Protocol-Version` header the transport stamps from the same source — a + * future modern-family revision would negotiate a different string, and the + * two must not disagree. The raw channel only runs on a connected modern + * session, so this is always set; the constant is a defensive fallback. + */ + private withModernEnvelope( + params: Record<string, unknown>, + clientCapabilities: ClientCapabilities = this.clientCapabilities, + ): Record<string, unknown> { + const existingMeta = + (params._meta as Record<string, unknown> | undefined) ?? {}; /* v8 ignore start -- fallback only if getProtocolVersion() is unset, which can't happen on the connected modern session this runs on. Bracketed so the ignore is reflow-proof however prettier splits the statement. */ @@ -3154,6 +3181,7 @@ export class InspectorClient extends InspectorClientEventTarget { itemsKey: "tools", itemSchema: ToolSchema, metadata: options?.metadata, + finalize: (salvaged) => this.excludeInvalidXMcpHeaderTools(salvaged), aggregate: async () => [ ...( await this.client!.listTools( @@ -3261,12 +3289,19 @@ export class InspectorClient extends InspectorClientEventTarget { itemSchema, aggregate, metadata, + finalize, }: { method: string; itemsKey: string; itemSchema: z.ZodType<T>; aggregate: () => Promise<T[]>; metadata?: Record<string, string>; + /** + * Filter applied to SALVAGED entries only, to reproduce a filter the SDK's + * strict aggregate applies for us. Without it the fallback would return a + * longer list than the strict path — see the `tools/list` caller. + */ + finalize?: (items: T[]) => T[]; }): Promise<T[]> { try { const items = await this.invokeMcpClient(aggregate, { method }); @@ -3274,10 +3309,35 @@ export class InspectorClient extends InspectorClientEventTarget { return items; } catch (err) { if (!isClientDecodeRejection(err)) throw err; - return this.salvageList({ method, itemsKey, itemSchema, metadata, err }); + const salvaged = await this.salvageList({ + method, + itemsKey, + itemSchema, + metadata, + err, + }); + return finalize ? finalize(salvaged) : salvaged; } } + /** + * Drop the tools the SDK would have excluded from `tools/list` for invalid + * `x-mcp-header` annotations (SEP-2243). + * + * The strict aggregate is filtered by the SDK before we see it, so the + * salvage path has to reapply the same rule itself: otherwise one ordinary + * malformed tool anywhere in the list would trip the fallback and quietly + * readmit every invalid-header tool alongside it — turning a rendering fix + * into a spec violation. The dropped tools are still reported through + * `refreshExcludedTools`, which lists them with their reason. + */ + private excludeInvalidXMcpHeaderTools(tools: Tool[]): Tool[] { + if (!this.excludesInvalidXMcpHeaderTools()) return tools; + return tools.filter( + (tool) => scanXMcpHeaderDeclarations(tool.inputSchema).valid, + ); + } + /** * Re-walk a list method with pagination validated but entries left raw, then * validate each entry on its own — keeping the good ones and reporting the @@ -3310,6 +3370,13 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?: Record<string, string>; err: unknown; }): Promise<T[]> { + // Capture the correlation id BEFORE re-walking. `markResponseRejected` + // resolves "the last response received for this method", and the re-walk + // below answers that same method one or more times — so marking afterwards + // would stamp the rejection onto a salvage page that succeeded and leave + // the actually-invalid response rendering as a clean success, which is the + // precise lie #1953 exists to remove. + const rejectedResponseId = this.lastAnsweredRequestByMethod.get(method); const valid: T[] = []; const malformed: MalformedListItem[] = []; const seenCursors = new Set<string>(); @@ -3320,13 +3387,27 @@ export class InspectorClient extends InspectorClientEventTarget { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), ...(cursor ? { cursor } : {}), }; + // The modern (2026-07-28) codec validates a result against the SPEC + // schema for the method before the caller's schema is consulted, so a + // lenient `client.request` is rejected exactly as the strict one was — + // there is no salvage without going around it. The raw-wire channel that + // exists for the modern `tasks/*` methods does precisely that: the frame + // goes straight through the transport (still logged for the Protocol / + // Network tabs) and only the caller's schema is applied. Legacy keeps the + // ordinary SDK path, which honors request options and `_meta` for us. const page = await this.invokeMcpClient( () => - this.client!.request( - { method, params }, - LenientListPageSchema, - this.getRequestOptions(metadata?.progressToken), - ), + this.isModernEra() + ? this.rawWireRequest( + method, + this.withModernEnvelope(params), + LenientListPageSchema, + ) + : this.client!.request( + { method, params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ), { method }, ); const salvaged = salvageListItems({ @@ -3349,7 +3430,12 @@ export class InspectorClient extends InspectorClientEventTarget { // this fallback cannot explain — surface it rather than hide it. if (malformed.length === 0) throw err; - this.markResponseRejected(method, summarizeMalformed(malformed)); + if (rejectedResponseId !== undefined) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: summarizeMalformed(malformed), + }); + } this.setMalformedListItems(method, malformed); this.logger.warn( { method, malformed }, @@ -3382,7 +3468,7 @@ export class InspectorClient extends InspectorClientEventTarget { const seenCursors = new Set<string>(); let cursor: string | undefined; do { - const page = await this.listTools(cursor, metadata); + const page = await this.listToolsForScan(cursor, metadata); for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3400,6 +3486,54 @@ export class InspectorClient extends InspectorClientEventTarget { return excluded; } + /** + * One page of the RAW `tools/list` for the SEP-2243 scan, tolerating a + * malformed sibling entry (#1632 + #1909). + * + * The scan needs the unfiltered list, so it uses the strict single-page call + * — but that call rejects the whole page when any entry is non-conforming, + * which would blank the excluded set and leave a tool that vanished for an + * invalid header with no explanation at all. That is the same failure #1909 + * is about, one level down. So on a decode rejection the page is re-fetched + * leniently and scanned entry by entry; the malformed ones are not "excluded + * tools" and are reported through the salvage channel instead. + */ + private async listToolsForScan( + cursor: string | undefined, + metadata: Record<string, string> | undefined, + ): Promise<{ tools: Tool[]; nextCursor?: string }> { + try { + return await this.listTools(cursor, metadata); + } catch (err) { + if (!isClientDecodeRejection(err)) throw err; + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + ...(cursor ? { cursor } : {}), + }; + const page = this.isModernEra() + ? await this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + LenientListPageSchema, + ) + : await this.client!.request( + { method: "tools/list", params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ); + const { valid } = salvageListItems({ + method: "tools/list", + items: rawItemsOf(page, "tools"), + schema: ToolSchema, + }); + return { + tools: valid, + ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + }; + } + } + /** * Call a tool. Caller must provide the Tool (e.g. from a state manager). * @param tool The tool to call (use tool.name for the request) From 2b2e1575111ec8911ccec63a3c916a36ee5b15b5 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 17:39:43 -0400 Subject: [PATCH 130/941] fix: gate the connect-time logging/setLevel on the negotiated era (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logging/setLevel` is a legacy-era method, but connect() called it on any server advertising the `logging` capability, era regardless. The modern wire era rejects it outright, and because the call sits in the post-handshake sequence that rejection failed the *connection* — so the CLI (the one caller passing `initialLoggingLevel`, hardcoded to "debug") could not run any method at all against a modern server that advertised logging. Gate it on `protocolEra !== "modern"`. Modern has no session-scoped level to set: the equivalent is the per-request `io.modelcontextprotocol/logLevel` `_meta` opt-in, already driven by `modernLogLevel` from the server settings. So modern skips the call rather than translating it. Legacy is unchanged. The guard lives next to the capability check in inspectorClient.ts rather than in the CLI, so any future caller passing `initialLoggingLevel` is covered too. Signed-off-by: cliffhall <cliff@futurescale.com> --- ...nspectorClient-initial-logging-era.test.ts | 127 ++++++++++++++++++ core/mcp/inspectorClient.ts | 19 ++- 2 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts diff --git a/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts new file mode 100644 index 00000000..1f5fa165 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-initial-logging-era.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { + MODERN_PROTOCOL_VERSION, + eraToVersionNegotiation, +} from "@inspector/core/mcp/types.js"; + +/** + * Regression coverage for #1990: the connect-time `initialLoggingLevel` must be + * gated on the negotiated era, not on the server capability alone. + * + * `logging/setLevel` is a legacy-era method, and the modern wire era rejects it + * outright ("Method 'logging/setLevel' is not supported by the negotiated + * protocol version"). Because the call sat in `connect()`'s post-handshake + * sequence, that rejection failed the *connection* — so the CLI (the one caller + * that passes `initialLoggingLevel`, hardcoded to "debug" in + * `clients/cli/src/cli.ts`) could not run *any* method against a modern server + * that advertised `logging`, logging-related or not. + * + * The two cases below are the era split: legacy still sends the request with the + * configured level, modern connects cleanly and never puts the method on the + * wire. The modern half asserts on the sent methods rather than on connect + * succeeding alone — a server that happens to answer the method would hide the + * regression behind a green connect. + * + * A fake transport carries both. The modern leg needs no `initialize` at all: + * the SDK negotiates with a `server/discover` probe and adopts that result's + * capabilities directly, which is also what lets one small class serve both eras + * by answering whichever handshake arrives. + */ +class CapabilityAdvertisingTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + /** Every method this client put on the wire, in order. */ + readonly sentMethods: string[] = []; + + /** The params of the `logging/setLevel` request, if one was sent. */ + loggingSetLevelParams?: Record<string, unknown>; + + async start(): Promise<void> {} + + async close(): Promise<void> { + this.onclose?.(); + } + + async send(message: JSONRPCMessage): Promise<void> { + if (!("method" in message)) return; + this.sentMethods.push(message.method); + + // The modern era's handshake: one probe, answered with the capabilities the + // client then treats as the server's. + if (message.method === "server/discover" && "id" in message) { + this.reply(message.id, { + supportedVersions: [MODERN_PROTOCOL_VERSION], + capabilities: { logging: {} }, + }); + return; + } + + // The legacy era's handshake. + if (message.method === "initialize" && "id" in message) { + const params = message.params as { protocolVersion: string }; + this.reply(message.id, { + protocolVersion: params.protocolVersion, + capabilities: { logging: {} }, + serverInfo: { name: "logging-server", version: "1.0.0" }, + }); + return; + } + + if (message.method === "logging/setLevel" && "id" in message) { + this.loggingSetLevelParams = message.params as Record<string, unknown>; + this.reply(message.id, {}); + return; + } + } + + private reply(id: string | number, result: Record<string, unknown>): void { + // The SDK resolves the request from its own microtask; delivering + // synchronously from inside `send()` is the earliest any server could. + this.onmessage?.({ jsonrpc: "2.0", id, result }); + } +} + +async function connectWith( + era: "legacy" | "modern", +): Promise<CapabilityAdvertisingTransport> { + const transport = new CapabilityAdvertisingTransport(); + const client = new InspectorClient( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + environment: { transport: () => ({ transport }) }, + versionNegotiation: eraToVersionNegotiation(era), + initialLoggingLevel: "debug", + }, + ); + + await client.connect(); + expect(client.getProtocolEra()).toBe(era); + await client.disconnect(); + + return transport; +} + +describe("InspectorClient connect() initial logging level, by era (#1990)", () => { + it("sends logging/setLevel on a legacy connection", async () => { + const transport = await connectWith("legacy"); + + expect(transport.sentMethods).toContain("logging/setLevel"); + expect(transport.loggingSetLevelParams).toMatchObject({ level: "debug" }); + }); + + it("never sends logging/setLevel on a modern connection", async () => { + const transport = await connectWith("modern"); + + // Modern has no session-scoped level: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, driven by the + // `modernLogLevel` server setting rather than by `initialLoggingLevel`. + expect(transport.sentMethods).not.toContain("logging/setLevel"); + // The server did advertise logging, so the capability check alone would + // have let the call through — the era gate is what stops it. + expect(transport.sentMethods).toContain("server/discover"); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..5d8aa86a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1936,8 +1936,23 @@ export class InspectorClient extends InspectorClientEventTarget { // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - // Set initial logging level if configured and server supports it - if (this.initialLoggingLevel && this.capabilities?.logging) { + // Set initial logging level if configured and server supports it. + // + // Era-gated (#1990): `logging/setLevel` is a legacy-era method, and the + // modern wire era rejects it outright — so an unconditional call here + // failed the *connect* of every modern server advertising `logging`, + // taking down commands that have nothing to do with logging. Modern has + // no session-scoped level at all: the equivalent is the per-request + // `io.modelcontextprotocol/logLevel` `_meta` opt-in, which + // `modernLogLevel` already drives from the server settings (see + // {@link setModernLogLevel}). So the right behavior on modern is to skip + // this call rather than translate it — `initialLoggingLevel` names a + // mechanism that era does not have. + if ( + this.initialLoggingLevel && + this.capabilities?.logging && + this.protocolEra !== "modern" + ) { await this.client.setLoggingLevel( this.initialLoggingLevel, this.getRequestOptions(), From 80eb321b1436fb324714984e68f228e305b1f9f1 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 17:49:09 -0400 Subject: [PATCH 131/941] fix: stop const-less anyOf branches from crashing the form, and never select a non-string enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2007. Two things, both surfaced while answering "does #1928's PR also fix #2007?". **#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level `const`, so every MultiSelect option was `String(item.const ?? "")` — the empty string, twice. Mantine *throws* on duplicate option values, greying out the whole tool panel. That is a pre-existing bug, but this branch makes it reachable from a new direction: a *nullable* array of object unions previously had no top-level `type` and fell to the JSON fallback, and now collapses to `type: "array"` with `items.anyOf` — straight into the crash. Shipping the collapse without this fix would widen an existing crash, so the two go together. Both shapes are covered by tests. `toConstOptions` now returns `null` unless every branch yields a distinct, non-empty value, and the field falls through to the JSON editor — which is the honest widget for a union of object shapes anyway. Applied to the `string`/`oneOf` path as well, which had the identical `?? ""` and the same duplicate-throw waiting on it. **Copilot round 2.** Round 1's fix kept "an explicit `type` is authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the type switch, and `toSelectOptions` stringifies, so that reached a select that submits `"1"` for `1`. The select path is now gated on `isStringEnum` (exported from core for this), and a typed non-string enum falls through to its typed field: the enum constraint is lost, the value's type is not, which is the safer of the two losses. Pre-existing for a plain numeric enum; the collapse just made the nullable form reachable too. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/tui/__tests__/schemaToForm.test.ts | 33 ++++++ clients/tui/src/utils/schemaToForm.ts | 16 ++- .../groups/SchemaForm/SchemaForm.test.tsx | 102 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 63 +++++++++-- core/json/nullableUnion.ts | 9 +- 5 files changed, 205 insertions(+), 18 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 93ca1ca9..769c3026 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -311,6 +311,39 @@ describe("schemaToForm", () => { ]); }); + // ink-form hands a select's stringified value straight back, so a numeric + // enum routed to a select would submit "1" for 1. The typed field loses the + // enum constraint but keeps the value's type — the safer loss. + it("routes a nullable numeric enum to its typed field, not a select", () => { + const form = schemaToForm( + { + properties: { + level: { + anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }], + }, + ratio: { + anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }], + }, + }, + }, + "numericEnum", + ); + expect( + form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })), + ).toEqual([ + { name: "level", type: "integer" }, + { name: "ratio", type: "float" }, + ]); + }); + + it("routes a plain numeric enum to its typed field too", () => { + const form = schemaToForm( + { properties: { level: { type: "integer", enum: [1, 2] } } }, + "plainNumericEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" }); + }); + it("leaves a union of two real types as a plain string field", () => { const form = schemaToForm( { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 5201abbb..6332450c 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -3,7 +3,10 @@ */ import type { FormStructure, FormSection, FormField } from "ink-form"; -import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { + isStringEnum, + normalizeNullableUnion, +} from "@inspector/core/json/nullableUnion.js"; /** Minimal JSON Schema property shape used when building tool parameter forms */ interface JsonSchemaProperty { @@ -105,12 +108,17 @@ export function schemaToForm( ...baseField, options: toSelectOptions(property.items.enum, property.items.enumNames), } as FormField; - } else if (property.enum) { - // Single select + } else if (isStringEnum(property.enum)) { + // Single select. Gated on the members being strings because + // `toSelectOptions` stringifies them and ink-form hands the string + // straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate + // the schema. A typed non-string enum falls through to its typed field + // below, which loses the enum constraint but keeps the value's type — + // the safer of the two losses. field = { type: "select", ...baseField, - options: toSelectOptions(property.enum, property.enumNames), + options: toSelectOptions(property.enum ?? [], property.enumNames), } as FormField; } else { // Map JSON Schema types to ink-form types diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 3ff41b50..e3bc2b3c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -971,6 +971,108 @@ describe("SchemaForm nullable unions", () => { expect(onChange).toHaveBeenCalledWith({ profile: { nick: "z" } }); }); + // #2007: `z.array(z.union([z.object(…), z.object(…)]))` gives an `items.anyOf` + // whose branches carry no top-level `const`, so every MultiSelect option was + // the empty string — and Mantine *throws* on duplicate option values, greying + // out the whole tool panel. The nullable form below is reachable only because + // this PR now collapses it into `type: "array"`, so it has to be safe too. + it("falls back to the JSON input for an array of object-union items", () => { + const objectBranch = (name: string): InspectorFormSchema => ({ + type: "object", + properties: { type: { type: "string", const: name } }, + required: ["type"], + }); + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [objectBranch("A"), objectBranch("B")] }, + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to the JSON input for a nullable array of object-union items", () => { + const objectBranch = (name: string): InspectorFormSchema => ({ + type: "object", + properties: { type: { type: "string", const: name } }, + required: ["type"], + }); + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + anyOf: [ + { type: "array", items: { anyOf: [objectBranch("A")] } }, + { type: "null" }, + ], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to the JSON input when two anyOf branches share a const", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [{ const: "dup" }, { const: "dup" }] }, + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + + it("falls back to a text input for a string oneOf with no consts", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + mode: { + title: "Mode", + type: "string", + oneOf: [{ type: "string" }, { type: "string" }], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Mode/).tagName).toBe("INPUT"); + }); + + it("still renders a MultiSelect when every anyOf branch has a distinct const", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [{ const: "a", title: "Alpha" }, { const: "b" }] }, + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByRole("textbox", { name: "Items" })).toBeInTheDocument(); + }); + it("still falls back to the JSON input for a union of two real types", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 628f2ee3..9c203901 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -11,7 +11,10 @@ import { import { useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import type { + InspectorFormSchema, + JsonSchemaConst, +} from "../../../utils/jsonUtils"; import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; const FieldLabel = Text.withProps({ @@ -52,6 +55,44 @@ function toEnumData( return values; } +/** + * Build `Select`/`MultiSelect` option data from a list of `oneOf`/`anyOf` + * branches that are expected to be constants. + * + * Returns `null` when the branches are not usable as options, which sends the + * field to the JSON fallback instead. Three ways that happens, all of which + * Mantine reacts to badly: + * + * - **No `const`.** An `anyOf` of *object* schemas — what + * `z.array(z.union([z.object(…), z.object(…)]))` compiles to — has no + * top-level `const` on any branch, so every option would be the empty + * string. Mantine **throws** on duplicate option values, which greys out the + * whole tool panel rather than degrading (#2007). + * - **Duplicate values.** Two branches sharing a `const` throw the same way. + * - **An empty option value**, which Mantine cannot render as selectable. + * + * A union of object shapes has no faithful dropdown anyway, so the JSON editor + * is the honest widget for it rather than a lossy or crashing one. + */ +function toConstOptions( + branches: (InspectorFormSchema | JsonSchemaConst)[], +): { value: string; label: string }[] | null { + const options: { value: string; label: string }[] = []; + const seen = new Set<string>(); + for (const branch of branches) { + if (branch.const === undefined || branch.const === null) { + return null; + } + const value = String(branch.const); + if (value === "" || seen.has(value)) { + return null; + } + seen.add(value); + options.push({ value, label: branch.title ?? value }); + } + return options.length > 0 ? options : null; +} + /** * Whether an enum `Select` should offer a clear affordance. Only a schema that * admits `null` does: clearing emits `null`, and an enum without a null branch @@ -248,11 +289,11 @@ export function SchemaForm({ } // string with oneOf - if (fieldSchema.type === "string" && fieldSchema.oneOf) { - const data = fieldSchema.oneOf.map((item) => ({ - value: String(item.const ?? ""), - label: item.title ?? String(item.const ?? ""), - })); + const oneOfData = fieldSchema.oneOf + ? toConstOptions(fieldSchema.oneOf) + : null; + if (fieldSchema.type === "string" && oneOfData) { + const data = oneOfData; return ( <Select key={fieldName} @@ -352,11 +393,11 @@ export function SchemaForm({ } // array with items having anyOf - if (fieldSchema.type === "array" && fieldSchema.items?.anyOf) { - const data = fieldSchema.items.anyOf.map((item) => ({ - value: String(item.const ?? ""), - label: item.title ?? String(item.const ?? ""), - })); + const itemsAnyOfData = fieldSchema.items?.anyOf + ? toConstOptions(fieldSchema.items.anyOf) + : null; + if (fieldSchema.type === "array" && itemsAnyOfData) { + const data = itemsAnyOfData; return ( <MultiSelect key={fieldName} diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 2adfdd51..ae26b042 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -53,8 +53,7 @@ function toBranch(value: unknown): Record<string, unknown> | null { } /** - * Whether a branch's `enum` is one a *typeless* branch can be read as a string - * enum. + * Whether an `enum` is one that can be rendered as a string-valued dropdown. * * JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are * all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing @@ -63,8 +62,12 @@ function toBranch(value: unknown): Record<string, unknown> | null { * would `String(...)` them and submit `"1"` where the server expects `1`. So * only an all-string enum earns the inference; anything else stays on the * fallback path, which renders the value honestly as JSON. + * + * Exported because the same question decides whether a *dispatcher* may route + * an enum to a select at all. The TUI's does, and its options are stringified, + * so an unguarded numeric enum there submits `"1"` where the server wants `1`. */ -function isStringEnum(value: unknown): boolean { +export function isStringEnum(value: unknown): boolean { return ( Array.isArray(value) && value.length > 0 && From 1889e04135b0631f1f0cd796b33a382e064218d1 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 17:52:36 -0400 Subject: [PATCH 132/941] fix: address Copilot round 2 on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four findings taken (one visible, three in the suppressed block), plus the `excludedTools` sibling leak the round-1 reply had deferred: - `rawItemsOf` read a missing or non-array list member as an empty page, so a top-level violation (`{ tools: "invalid" }`) on a later page was discarded and the call returned a silently truncated list. It now returns `undefined` for "not a list at all", and the walk rethrows the original strict error. - A failure of the re-walk ITSELF (a malformed `nextCursor`, a connection lost mid-walk) escaped in place of the original error, whose response is the one `rejectedResponseId` was captured for. The walk is now wrapped: the original surfaces, the secondary is logged so it stays diagnosable. - The exclusion scan's lenient fallback discarded its malformed entries despite the comment claiming they were reported. Observable when the aggregate is served from cache: the salvage never runs, so nothing else reports them. The scan now returns them and `refreshExcludedTools` reports them — only ever adding, so a clean scan can't clear what the salvage path just wrote. - An empty-string cursor is a valid cursor, and `cursor ? {cursor} : {}` dropped it, re-requesting page one and duplicating its entries until the repeated-cursor guard fired. Both walks now test `!== undefined`. - `excludedTools` had the same session leak fixed for `malformedListItems` last round: cleared only on an explicit `disconnect()`, so the `onerror`-with-no- `onclose` route carried the previous server's set into the next connection. Cleared on the start-clean path too — safe because every `listAllTools` recomputes it, so this can only drop a stale set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/listSalvage.test.ts | 14 +- .../inspectorClient-excluded-tools.test.ts | 22 +++ .../inspectorClient-malformed-list.test.ts | 74 +++++++- core/mcp/inspectorClient.ts | 163 ++++++++++++------ core/mcp/listSalvage.ts | 18 +- 5 files changed, 229 insertions(+), 62 deletions(-) diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts index 08445051..f0fd7fdc 100644 --- a/clients/web/src/test/core/mcp/listSalvage.test.ts +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -66,14 +66,16 @@ describe("LenientListPageSchema", () => { ).toBe(false); }); - it("reads an absent or non-array items key as no entries", () => { - expect(rawItemsOf({}, "tools")).toEqual([]); + it("distinguishes an empty page from one that is not a list at all", () => { + // Reading either as "no entries" would let a top-level violation pass as a + // silently truncated list. expect( - rawItemsOf( - LenientListPageSchema.parse({ tools: "not-a-list" }) as never, - "tools", - ), + rawItemsOf(LenientListPageSchema.parse({ tools: [] }), "tools"), ).toEqual([]); + expect(rawItemsOf({}, "tools")).toBeUndefined(); + expect( + rawItemsOf(LenientListPageSchema.parse({ tools: "not-a-list" }), "tools"), + ).toBeUndefined(); }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts index fa3f9843..8f3bbf88 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-excluded-tools.test.ts @@ -120,6 +120,28 @@ describe("excluded tools (SEP-2243 x-mcp-header)", () => { expect(excluded.map((x) => x.tool.name)).toEqual(["invalid_header_tool"]); }); + it("does not carry an excluded set into the next connection (#1909)", async () => { + // `resetSessionState()` is the start-clean path, and it is the ONLY one the + // route out that tears down nothing (`onerror` with no `onclose`) reaches. + // A set left behind names tools of the PREVIOUS server — and would persist + // indefinitely against a server that never lists tools again. + const started = await start({}); + const connected = await connect(started.url, "modern"); + await connected.listAllTools(); + expect(connected.getExcludedTools()).toHaveLength(1); + + const events: number[] = []; + connected.addEventListener("excludedToolsChange", (e) => { + events.push(e.detail.length); + }); + // Reconnecting runs the start-clean reset; assert before anything re-lists. + await connected.disconnect(); + await connected.connect(); + + expect(connected.getExcludedTools()).toEqual([]); + expect(events).toContain(0); + }); + it("reports no excluded tools on a legacy connection (no exclusion there)", async () => { const started = await start(undefined); const connected = await connect(started.url, "legacy"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index db04bb13..039dcbc0 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -51,6 +51,8 @@ function startMalformedServer( url: string; stop: () => Promise<void>; calls: string[]; + /** Every `cursor` param the server was sent, in order. */ + cursors: (string | undefined)[]; /** Swap what the server serves next, to model a server that got fixed. */ setPages: (next: { resourceTemplates?: ListPage[]; @@ -60,10 +62,21 @@ function startMalformedServer( const calls: string[] = []; let pages = initialPages; + const cursors: (string | undefined)[] = []; + // Cursor string -> the page index it addresses, learned as pages are served. + // Modeling it this way (rather than parsing the cursor as a number) is what + // makes an EMPTY-STRING cursor meaningful: a client that drops it sends no + // cursor at all, lands back on page one, and duplicates its entries. + const cursorToIndex = new Map<string, number>(); const pageFor = (key: "resourceTemplates" | "tools", cursor?: string) => { + cursors.push(cursor); const configured = pages[key] ?? []; - const index = cursor === undefined ? 0 : Number(cursor); - return configured[index] ?? { items: [] }; + const index = cursor === undefined ? 0 : (cursorToIndex.get(cursor) ?? 0); + const page = configured[index] ?? { items: [] }; + if (page.nextCursor !== undefined) { + cursorToIndex.set(page.nextCursor, index + 1); + } + return page; }; const handler = async (req: IncomingMessage, res: ServerResponse) => { @@ -160,6 +173,7 @@ function startMalformedServer( resolve({ url: `http://127.0.0.1:${port}/mcp`, calls, + cursors, setPages: (next) => { pages = next; }, @@ -414,6 +428,62 @@ describe("InspectorClient list salvage (#1909)", () => { ]); }); + it("surfaces the ORIGINAL error when the re-walk itself fails", async () => { + // A malformed `nextCursor` fails the lenient page schema too, so the walk + // throws its own error. The original is what the caller's list actually + // failed on — and what `rejectedResponseId` was captured for — so that is + // the one that must surface. + const server = await startMalformedServer({ + resourceTemplates: [ + { + items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE], + nextCursor: 42 as unknown as string, + }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await expect(connected.listAllResourceTemplates()).rejects.toThrow( + /annotations/, + ); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("keeps the strict error when a page is not a list at all", async () => { + // `{ resourceTemplates: "nope" }` is a top-level violation the per-item + // pass cannot explain; treating it as an empty page would return a + // silently truncated list and discard the error that was right about it. + const server = await startMalformedServer({ + resourceTemplates: [{ items: "nope" as unknown as unknown[] }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("sends an empty-string cursor rather than re-fetching page one", async () => { + // "" is a valid cursor. A truthiness check would drop it, re-request page + // one, and duplicate its entries until the repeated-cursor guard fired. + const server = await startMalformedServer({ + resourceTemplates: [ + { items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE], nextCursor: "" }, + { items: [{ ...VALID_TEMPLATE, name: "page_two" }] }, + ], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + const { resourceTemplates } = await connected.listAllResourceTemplates(); + expect(resourceTemplates.map((t) => t.name)).toEqual([ + "full_annotations", + "page_two", + ]); + expect(server.cursors).toContain(""); + }); + it("rethrows when the rejection is not about any single entry", async () => { // Every entry validates; the result is bad for another reason (a cursor of // the wrong type). There is no per-item story, so the original error must diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 785c0c35..ac6d5f00 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1616,15 +1616,23 @@ export class InspectorClient extends InspectorClientEventTarget { // (#1953). this.outboundRequestMethods.clear(); this.lastAnsweredRequestByMethod.clear(); - // Per-session for the same reason: a report names entries of the PREVIOUS + // Per-session for the same reason: both name entries of the PREVIOUS // server's list. Cleared here as well as in `disconnect()` because the // route out that tears down nothing (`onerror` with no `onclose`) would - // otherwise carry it into the next connection — indefinitely, if that - // server never lists the method again (#1909). + // otherwise carry them into the next connection — indefinitely, if that + // server never lists the method again. Safe to clear unconditionally at + // connect: `listAllTools` recomputes the excluded set on every aggregate + // and the salvage report on every list, so this can only drop a stale set, + // never a live one (#1909; the `excludedTools` half is the same latent bug + // from #1632, fixed here rather than left as a known sibling). if (this.malformedListItems.size > 0) { this.malformedListItems.clear(); this.dispatchTypedEvent("malformedListItemsChange", []); } + if (this.excludedTools.length > 0) { + this.excludedTools = []; + this.dispatchTypedEvent("excludedToolsChange", []); + } for (const [, controller] of this.taskInputAbortControllers) { controller.abort(new Error("Connection ended")); } @@ -3381,50 +3389,75 @@ export class InspectorClient extends InspectorClientEventTarget { const malformed: MalformedListItem[] = []; const seenCursors = new Set<string>(); let cursor: string | undefined; - do { - const effectiveMeta = this.mergeMeta(metadata); - const params = { - ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - ...(cursor ? { cursor } : {}), - }; - // The modern (2026-07-28) codec validates a result against the SPEC - // schema for the method before the caller's schema is consulted, so a - // lenient `client.request` is rejected exactly as the strict one was — - // there is no salvage without going around it. The raw-wire channel that - // exists for the modern `tasks/*` methods does precisely that: the frame - // goes straight through the transport (still logged for the Protocol / - // Network tabs) and only the caller's schema is applied. Legacy keeps the - // ordinary SDK path, which honors request options and `_meta` for us. - const page = await this.invokeMcpClient( - () => - this.isModernEra() - ? this.rawWireRequest( - method, - this.withModernEnvelope(params), - LenientListPageSchema, - ) - : this.client!.request( - { method, params }, - LenientListPageSchema, - this.getRequestOptions(metadata?.progressToken), - ), - { method }, - ); - const salvaged = salvageListItems({ - method, - items: rawItemsOf(page, itemsKey), - schema: itemSchema, - startIndex: valid.length + malformed.length, - }); - valid.push(...salvaged.valid); - malformed.push(...salvaged.malformed); - cursor = page.nextCursor; - if (cursor !== undefined) { - /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ - if (seenCursors.has(cursor)) break; - seenCursors.add(cursor); + try { + do { + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + // `!== undefined`, not truthiness: "" is a valid cursor, and dropping it + // would re-request page one and duplicate its entries until the + // repeated-cursor guard below stopped the walk. + ...(cursor !== undefined && { cursor }), + }; + // The modern (2026-07-28) codec validates a result against the SPEC + // schema for the method before the caller's schema is consulted, so a + // lenient `client.request` is rejected exactly as the strict one was — + // there is no salvage without going around it. The raw-wire channel that + // exists for the modern `tasks/*` methods does precisely that: the frame + // goes straight through the transport (still logged for the Protocol / + // Network tabs) and only the caller's schema is applied. Legacy keeps the + // ordinary SDK path, which honors request options and `_meta` for us. + const page = await this.invokeMcpClient( + () => + this.isModernEra() + ? this.rawWireRequest( + method, + this.withModernEnvelope(params), + LenientListPageSchema, + ) + : this.client!.request( + { method, params }, + LenientListPageSchema, + this.getRequestOptions(metadata?.progressToken), + ), + { method }, + ); + const items = rawItemsOf(page, itemsKey); + // The page's list member is missing or isn't a list: a top-level + // violation, not a per-item one. Reading it as "no entries" would return + // a silently truncated list and discard the strict error that was right + // about it. + if (items === undefined) throw err; + const salvaged = salvageListItems({ + method, + items, + schema: itemSchema, + startIndex: valid.length + malformed.length, + }); + valid.push(...salvaged.valid); + malformed.push(...salvaged.malformed); + cursor = page.nextCursor; + if (cursor !== undefined) { + /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ + if (seenCursors.has(cursor)) break; + seenCursors.add(cursor); + } + } while (cursor !== undefined); + } catch (walkErr) { + // The re-walk itself failed — its own decode rejection (a malformed + // `nextCursor`, an unsalvageable page), or the connection going away + // mid-walk. Either way the ORIGINAL error is the one to surface: it is + // what the caller's list actually failed on, and `rejectedResponseId` + // above was captured for that response, not this one. The secondary + // failure is logged so it stays diagnosable rather than vanishing. + if (walkErr !== err) { + this.logger.warn( + { err: walkErr, method }, + "Salvage re-walk failed; surfacing the original list error", + ); } - } while (cursor !== undefined); + throw err; + } // Nothing per-item was wrong, so the strict rejection was about something // this fallback cannot explain — surface it rather than hide it. @@ -3462,6 +3495,7 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?: Record<string, string>, ): Promise<ExcludedTool[]> { const excluded: ExcludedTool[] = []; + const malformed: MalformedListItem[] = []; // Gated to connections that actually exclude; otherwise this is a pure // no-op (no round trip). The raw `listTools` below guards the connection. if (this.excludesInvalidXMcpHeaderTools()) { @@ -3469,6 +3503,13 @@ export class InspectorClient extends InspectorClientEventTarget { let cursor: string | undefined; do { const page = await this.listToolsForScan(cursor, metadata); + malformed.push( + ...page.malformed.map((entry) => ({ + ...entry, + // Index against the aggregate this walk is building, not the page. + index: entry.index + excluded.length + page.tools.length, + })), + ); for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3481,6 +3522,15 @@ export class InspectorClient extends InspectorClientEventTarget { } } while (cursor !== undefined); } + // Report what the scan itself had to drop. This walk is deliberately + // un-cached, so it can see a malformed tool the `listAllTools` aggregate + // never did — that aggregate may have been served from the response cache, + // in which case its salvage never ran and cleared the report instead. Only + // ever ADDS: a scan that finds nothing must not clear a report the salvage + // path just wrote from its own walk of the same list. + if (malformed.length > 0) { + this.setMalformedListItems("tools/list", malformed); + } this.excludedTools = excluded; this.dispatchTypedEvent("excludedToolsChange", excluded); return excluded; @@ -3501,15 +3551,21 @@ export class InspectorClient extends InspectorClientEventTarget { private async listToolsForScan( cursor: string | undefined, metadata: Record<string, string> | undefined, - ): Promise<{ tools: Tool[]; nextCursor?: string }> { + ): Promise<{ + tools: Tool[]; + nextCursor?: string; + malformed: MalformedListItem[]; + }> { try { - return await this.listTools(cursor, metadata); + const page = await this.listTools(cursor, metadata); + return { ...page, malformed: [] }; } catch (err) { if (!isClientDecodeRejection(err)) throw err; const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - ...(cursor ? { cursor } : {}), + // See `salvageList`: "" is a valid cursor. + ...(cursor !== undefined && { cursor }), }; const page = this.isModernEra() ? await this.rawWireRequest( @@ -3522,14 +3578,19 @@ export class InspectorClient extends InspectorClientEventTarget { LenientListPageSchema, this.getRequestOptions(metadata?.progressToken), ); - const { valid } = salvageListItems({ + const items = rawItemsOf(page, "tools"); + // Not a list at all — a top-level violation the per-item pass can't + // explain, so the strict error stands (mirrors `salvageList`). + if (items === undefined) throw err; + const { valid, malformed } = salvageListItems({ method: "tools/list", - items: rawItemsOf(page, "tools"), + items, schema: ToolSchema, }); return { tools: valid, ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + malformed, }; } } diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index a144c2bd..5d12f5da 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -93,10 +93,22 @@ export const LenientListPageSchema = z.looseObject({ export type LenientListPage = z.infer<typeof LenientListPageSchema>; -/** Read the primitive array out of a leniently-parsed page. */ -export function rawItemsOf(page: LenientListPage, itemsKey: string): unknown[] { +/** + * Read the primitive array out of a leniently-parsed page. + * + * Returns `undefined` — NOT an empty array — when the member is missing or is + * not an array, because those are different facts. An empty page is a normal + * server answer; a page whose `tools` is the string `"invalid"` is a top-level + * schema violation this fallback cannot explain, and reading it as "no entries" + * would let the caller return a silently truncated list while the strict error + * that was correct about it is discarded. + */ +export function rawItemsOf( + page: LenientListPage, + itemsKey: string, +): unknown[] | undefined { const value = (page as Record<string, unknown>)[itemsKey]; - return Array.isArray(value) ? value : []; + return Array.isArray(value) ? value : undefined; } /** From aea3f6d4c2b9536b840ed35c3c6e79df015dec1d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:07:12 -0400 Subject: [PATCH 133/941] fix: address Copilot round 3 on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visible review was clean; all five findings were in the suppressed block, two of them real defects. - The exclusion scan's aggregate offset was wrong twice over: `excluded.length` counts only invalid-header tools, and on page one it also folded in the current page's valid count. A reported index therefore pointed at a position that doesn't exist — worst for an unlabeled entry, whose index is all the user has. Replaced with a raw-entry offset advanced by each page's valid + malformed counts. - The scan's own refused response was never marked. It is a distinct exchange from the aggregate's, and the lenient re-fetch moves the method correlation off it, so its Protocol entry rendered as a clean success the client had in fact refused. Marked with the captured id, as `salvageList` already does. - Three double casts in the test fixture are gone: `ListPage` now models raw wire data (`items: unknown`, `nextCursor?: unknown`), which is what a server built to emit spec-forbidden shapes actually serves. Declaring the validated type and casting malformed values through it asserted the opposite of what these tests exist to check. The cursor map narrows to `string` at the one place a cursor addresses a page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../inspectorClient-malformed-list.test.ts | 95 +++++++++++++++++-- core/mcp/inspectorClient.ts | 25 ++++- 2 files changed, 110 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index 039dcbc0..e562d6f6 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -26,9 +26,18 @@ import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; const MODERN_VERSION = "2026-07-28"; +/** + * One page exactly as the server will put it on the wire. + * + * Both fields are `unknown` on purpose: this server exists to serve shapes the + * spec forbids (a non-array `items`, a numeric `nextCursor`), so the fixture + * models raw wire data rather than the validated type. Declaring the spec type + * and casting malformed values through it at each call site would assert the + * opposite of what these tests are for. + */ interface ListPage { - items: unknown[]; - nextCursor?: string; + items: unknown; + nextCursor?: unknown; } function jsonRpcResult(id: unknown, result: unknown) { @@ -73,7 +82,9 @@ function startMalformedServer( const configured = pages[key] ?? []; const index = cursor === undefined ? 0 : (cursorToIndex.get(cursor) ?? 0); const page = configured[index] ?? { items: [] }; - if (page.nextCursor !== undefined) { + // Only a string cursor can address a page; a malformed one is served + // verbatim so the client can reject it, but it maps to nothing. + if (typeof page.nextCursor === "string") { cursorToIndex.set(page.nextCursor, index + 1); } return page; @@ -437,7 +448,7 @@ describe("InspectorClient list salvage (#1909)", () => { resourceTemplates: [ { items: [PHP_EMPTY_ANNOTATIONS, VALID_TEMPLATE], - nextCursor: 42 as unknown as string, + nextCursor: 42, }, ], }); @@ -455,7 +466,7 @@ describe("InspectorClient list salvage (#1909)", () => { // pass cannot explain; treating it as an empty page would return a // silently truncated list and discard the error that was right about it. const server = await startMalformedServer({ - resourceTemplates: [{ items: "nope" as unknown as unknown[] }], + resourceTemplates: [{ items: "nope" }], }); stopServer = server.stop; const connected = await connectTo(server.url); @@ -484,14 +495,82 @@ describe("InspectorClient list salvage (#1909)", () => { expect(server.cursors).toContain(""); }); + it("marks the exclusion scan's own refused response too", async () => { + // The scan is a separate exchange from the aggregate's, and its re-fetch + // moves the method correlation off it — so without marking it here its + // Protocol entry renders as a clean success the client actually refused. + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "ok_tool", inputSchema: { type: "object" } }, + { name: "broken", inputSchema: "not-a-schema" }, + ], + }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + const log = new MessageLogState(connected); + + await connected.listAllTools(); + + const marked = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "tools/list" && + entry.clientError !== undefined, + ); + // Two refused responses: the aggregate's and the scan's. + expect(marked).toHaveLength(2); + for (const entry of marked) { + expect(entry.clientError).toContain("Dropped 1 malformed entry"); + } + log.destroy(); + }); + + it("reports scan-dropped tools at their position in the whole list", async () => { + // The exclusion scan walks pages of its own, so its report has to be + // offset by the RAW entries of PRIOR pages. An unlabeled entry's index is + // all the user has to go on, so a wrong offset points at nothing. + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "one", inputSchema: { type: "object" } }, + { name: "two", inputSchema: { type: "object" } }, + ], + nextCursor: "p2", + }, + // Page two, entry one (index 2 of the whole list) is unlabelable. + { items: [42, { name: "four", inputSchema: { type: "object" } }] }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + + const { tools } = await connected.listAllTools(); + expect(tools.map((t) => t.name)).toEqual(["one", "two", "four"]); + const reported = connected.getMalformedListItems(); + expect(reported).toHaveLength(1); + expect(reported[0]?.index).toBe(2); + expect(reported[0]?.label).toBeUndefined(); + }); + it("rethrows when the rejection is not about any single entry", async () => { // Every entry validates; the result is bad for another reason (a cursor of // the wrong type). There is no per-item story, so the original error must // survive rather than being swallowed into a partial list. const server = await startMalformedServer({ - resourceTemplates: [ - { items: [VALID_TEMPLATE], nextCursor: 42 as unknown as string }, - ], + resourceTemplates: [{ items: [VALID_TEMPLATE], nextCursor: 42 }], }); stopServer = server.stop; const connected = await connectTo(server.url); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index ac6d5f00..df03a016 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3496,6 +3496,9 @@ export class InspectorClient extends InspectorClientEventTarget { ): Promise<ExcludedTool[]> { const excluded: ExcludedTool[] = []; const malformed: MalformedListItem[] = []; + // Raw entries seen so far, across pages — the frame the reported indices + // are relative to (see the mapping below). + let rawOffset = 0; // Gated to connections that actually exclude; otherwise this is a pure // no-op (no round trip). The raw `listTools` below guards the connection. if (this.excludesInvalidXMcpHeaderTools()) { @@ -3506,10 +3509,15 @@ export class InspectorClient extends InspectorClientEventTarget { malformed.push( ...page.malformed.map((entry) => ({ ...entry, - // Index against the aggregate this walk is building, not the page. - index: entry.index + excluded.length + page.tools.length, + // Offset by the RAW entries of PRIOR pages only. `excluded.length` + // would be wrong twice over: it counts just the invalid-header + // tools, and on page one it would also fold in this page's own + // valid count — putting an unlabeled entry, whose index is all the + // user has to go on, at a position that doesn't exist. + index: entry.index + rawOffset, })), ); + rawOffset += page.tools.length + page.malformed.length; for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3561,6 +3569,13 @@ export class InspectorClient extends InspectorClientEventTarget { return { ...page, malformed: [] }; } catch (err) { if (!isClientDecodeRejection(err)) throw err; + // Capture before the re-fetch, for the reason `salvageList` documents: + // this refused response is a distinct exchange from the aggregate's, and + // the re-fetch below moves the method's correlation off it. Without this + // the scan's Protocol entry renders as a clean success even though the + // client refused the result. + const rejectedResponseId = + this.lastAnsweredRequestByMethod.get("tools/list"); const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), @@ -3587,6 +3602,12 @@ export class InspectorClient extends InspectorClientEventTarget { items, schema: ToolSchema, }); + if (rejectedResponseId !== undefined && malformed.length > 0) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: summarizeMalformed(malformed), + }); + } return { tools: valid, ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), From e746665eb6891334a6b391c47bd774e716415d27 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:10:16 -0400 Subject: [PATCH 134/941] docs: mark initialLoggingLevel as legacy-era only (#1990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the option's doc still promised the level is set "if server supports logging" — after the era gate that is exactly the case which now silently does nothing, since a modern server can advertise `logging` and still ignore the option. Document it as legacy-only and point modern callers at the per-request `io.modelcontextprotocol/logLevel` opt-in (`modernLogLevel` / `setModernLogLevel()`). Signed-off-by: cliffhall <cliff@futurescale.com> --- core/mcp/types.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b89f117b..3873ecea 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -919,8 +919,16 @@ export interface InspectorClientOptions { pipeStderr?: boolean; /** - * Initial logging level to set after connection (if server supports logging) - * If not provided, logging level will not be set automatically + * Initial logging level to set after connection, via `logging/setLevel`. + * If not provided, the logging level will not be set automatically. + * + * **Legacy era only (#1990).** `logging/setLevel` is a legacy-era method, and + * the modern (2026-07-28) era rejects it — so this option is ignored on a + * modern connection even when the server advertises `logging`. Modern has no + * session-scoped level at all: the equivalent is the per-request + * `io.modelcontextprotocol/logLevel` `_meta` opt-in, configured through the + * `modernLogLevel` server setting and applied via + * `InspectorClient.setModernLogLevel()`. */ initialLoggingLevel?: LoggingLevel; From 34ba8256a4e0ba5ae480997e54ac1acc2a846bf4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:15:38 -0400 Subject: [PATCH 135/941] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=203=20=E2=80=94=20typed=20consts,=20null=20admission,=20pres?= =?UTF-8?q?et=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings (one inline, two in the suppressed block), all valid. 1. `toConstOptions` still stringified every non-null `const`, so `items: { anyOf: [{ const: 1 }, { const: 2 }] }` submitted `["1"]` where the schema says `[1]`. Same wrong-type-on-the-wire problem that keeps a numeric `enum` off the select path, so it gets the same answer: only a string `const` is selectable, anything else falls to the JSON editor where the value keeps its type. 2. `hasMissingRequiredFields` derived null admission from the *renderer's* collapse, which is narrower on purpose — it only flattens a two-member union. So `anyOf: [string, number, null]` renders through the JSON fallback (where `null` is typeable) yet was still marked missing, disabling submit on a value the schema accepts. Null admission is a validity question, not a rendering one, so it is now its own predicate: `admitsNull` in core, recognizing `nullable`, `type: "null"`, `type: [..., "null"]`, and a null branch anywhere in an `anyOf`/`oneOf` of any size. 3. Nothing exercised the `record_shipment` preset — the web and TUI tests build the `anyOf` shape by hand, which verifies the renderers but *assumes* the premise the whole fix rests on: that Zod's `.nullish()` emits `anyOf: [<branch>, { type: "null" }]` with the enum on the branch. A Zod output change or a registry typo would leave every unit test green while the documented reproduction server quietly stopped reproducing. Added an integration test that boots the preset over HTTP, asserts the wire shape, and runs it back through the collapse — following the `duplicate-tool-names.test.ts` precedent, which exists for the same reason (#1957). Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 20 +++ .../groups/SchemaForm/SchemaForm.tsx | 14 +- .../web/src/test/core/nullableUnion.test.ts | 50 ++++++- .../integration/mcp/nullable-fields.test.ts | 122 ++++++++++++++++++ clients/web/src/utils/jsonUtils.test.ts | 16 +++ clients/web/src/utils/jsonUtils.ts | 15 ++- core/json/nullableUnion.ts | 44 +++++++ 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/nullable-fields.test.ts diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index e3bc2b3c..287da67b 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1022,6 +1022,26 @@ describe("SchemaForm nullable unions", () => { expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); }); + // Mantine's select is string-valued, so a numeric const would submit "1" for + // 1 — the same wrong-type-on-the-wire problem that keeps a numeric enum off + // the select path. + it("falls back to the JSON input for non-string anyOf consts", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + items: { + title: "Items", + type: "array", + items: { anyOf: [{ const: 1 }, { const: 2 }] }, + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Items/).tagName).toBe("TEXTAREA"); + }); + it("falls back to the JSON input when two anyOf branches share a const", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 9c203901..9cba4bd9 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -60,10 +60,14 @@ function toEnumData( * branches that are expected to be constants. * * Returns `null` when the branches are not usable as options, which sends the - * field to the JSON fallback instead. Three ways that happens, all of which - * Mantine reacts to badly: + * field to the JSON fallback instead. Four ways that happens: * - * - **No `const`.** An `anyOf` of *object* schemas — what + * - **A non-string `const`.** Mantine's select is string-valued, so + * `anyOf: [{ const: 1 }, { const: 2 }]` would submit `["1"]` where the + * schema says `[1]` — the same wrong-type-on-the-wire problem that keeps a + * numeric `enum` off the select path. An inspector must not misreport what + * it sends, so this stays on the JSON editor, where the value keeps its type. + * - **No `const` at all.** An `anyOf` of *object* schemas — what * `z.array(z.union([z.object(…), z.object(…)]))` compiles to — has no * top-level `const` on any branch, so every option would be the empty * string. Mantine **throws** on duplicate option values, which greys out the @@ -80,10 +84,10 @@ function toConstOptions( const options: { value: string; label: string }[] = []; const seen = new Set<string>(); for (const branch of branches) { - if (branch.const === undefined || branch.const === null) { + if (typeof branch.const !== "string") { return null; } - const value = String(branch.const); + const value = branch.const; if (value === "" || seen.has(value)) { return null; } diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 915e7e8e..485febf1 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { + admitsNull, + normalizeNullableUnion, +} from "@inspector/core/json/nullableUnion.js"; // The two JSON Schema encodings of "this type, or null". Both form builders — // the web `SchemaForm` and the TUI's `schemaToForm` — dispatch on a single @@ -281,3 +284,48 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); }); + +// `admitsNull` answers a *validity* question and is deliberately decoupled from +// the collapse, which answers a narrower *rendering* one. A schema can admit +// null while being unrenderable as a single widget, and a form that conflated +// the two would reject a value its own schema accepts. +describe("admitsNull", () => { + it("recognizes every encoding the collapse handles", () => { + expect(admitsNull({ anyOf: [{ type: "string" }, { type: "null" }] })).toBe( + true, + ); + expect(admitsNull({ type: ["string", "null"] })).toBe(true); + expect(admitsNull({ type: "null" })).toBe(true); + expect(admitsNull({ nullable: true })).toBe(true); + }); + + it("recognizes a null branch the collapse declines to flatten", () => { + // Three members, so `normalizeNullableUnion` leaves this alone and it + // renders through the JSON fallback — where a user can still enter `null`. + const wide = { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }; + expect(normalizeNullableUnion(wide)).toBe(wide); + expect(admitsNull(wide)).toBe(true); + }); + + it("recognizes a null branch in a oneOf, and a nested type array", () => { + expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe( + true, + ); + expect(admitsNull({ anyOf: [{ type: ["string", "null"] }] })).toBe(true); + }); + + it("is false for a schema that does not permit null", () => { + expect(admitsNull({ type: "string" })).toBe(false); + expect(admitsNull({ type: ["string", "number"] })).toBe(false); + expect( + admitsNull({ anyOf: [{ type: "string" }, { type: "number" }] }), + ).toBe(false); + expect(admitsNull({})).toBe(false); + }); + + it("ignores non-object anyOf members instead of throwing", () => { + expect(admitsNull({ anyOf: ["null", ["null"], 7] })).toBe(false); + }); +}); diff --git a/clients/web/src/test/integration/mcp/nullable-fields.test.ts b/clients/web/src/test/integration/mcp/nullable-fields.test.ts new file mode 100644 index 00000000..0a54e142 --- /dev/null +++ b/clients/web/src/test/integration/mcp/nullable-fields.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { toFormSchema } from "../../../utils/jsonUtils"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createNullableFieldsTool, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of the `record_shipment` preset behind + * `test-servers/configs/nullable-fields-http.json` — the documented manual + * reproduction for #1928. + * + * The unit tests on both form builders construct the `anyOf` shape by hand, + * which verifies the *renderers* but assumes the premise: that Zod's + * `.nullish()` actually emits `anyOf: [<branch>, { type: "null" }]` with the + * `enum` on the branch. That premise is the whole reason the fix exists, and + * nothing else pins it — a Zod change in how it compiles a nullish enum, or a + * typo in the preset registry, would leave every unit test green while the + * showcase server quietly stopped reproducing the bug. + * + * So this asserts the wire shape end to end, then runs it through the same + * collapse the forms use. + */ +describe("nullable argument schemas over the wire (#1928)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + async function connectToPreset(): Promise<InspectorClient> { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("nullable-fields-test", "1.0.0"), + tools: [createNullableFieldsTool()], + }); + await started.start(); + server = started; + + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { environment: { transport: createTransportNode } }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("emits an anyOf-with-null for every nullish argument, enum on the branch", async () => { + const connected = await connectToPreset(); + const { tools } = await connected.listAllTools(); + + const tool = tools.find((entry) => entry.name === "record_shipment"); + expect(tool).toBeDefined(); + + const schema = toFormSchema(tool?.inputSchema); + const properties = schema?.properties ?? {}; + expect(Object.keys(properties).sort()).toEqual([ + "direction", + "express", + "quantity", + "reference", + ]); + + // The premise of the whole fix: no top-level `type`, and the `enum` sits on + // the surviving branch rather than beside it. + const direction = properties.direction; + expect(direction?.type).toBeUndefined(); + expect(direction?.enum).toBeUndefined(); + expect(direction?.anyOf).toHaveLength(2); + expect(direction?.anyOf).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "null" })]), + ); + }); + + it("collapses each argument to the type its widget dispatches on", async () => { + const connected = await connectToPreset(); + const { tools } = await connected.listAllTools(); + const schema = toFormSchema( + tools.find((entry) => entry.name === "record_shipment")?.inputSchema, + ); + const properties = schema?.properties ?? {}; + + const collapsed = Object.fromEntries( + Object.entries(properties).map(([name, propertySchema]) => [ + name, + normalizeNullableUnion(propertySchema), + ]), + ); + + expect(collapsed.direction?.type).toBe("string"); + expect(collapsed.direction?.enum).toEqual(["envio", "recebimento"]); + expect(collapsed.reference?.type).toBe("string"); + expect(collapsed.quantity?.type).toBe("integer"); + expect(collapsed.express?.type).toBe("boolean"); + + for (const propertySchema of Object.values(collapsed)) { + expect(propertySchema?.nullable).toBe(true); + expect(propertySchema?.anyOf).toBeUndefined(); + } + }); +}); diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 4a69409c..8f61841c 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -315,6 +315,22 @@ describe("hasMissingRequiredFields", () => { expect(hasMissingRequiredFields(schema, { name: null })).toBe(true); }); + // The renderer's collapse only handles a two-member union, but null admission + // has no such limit — this one renders through the JSON fallback, where the + // user can still type `null`, and the schema plainly accepts it. + it("accepts an explicit null for a required three-member union", () => { + const wide: InspectorFormSchema = { + type: "object", + properties: { + mixed: { + anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }], + }, + }, + required: ["mixed"], + }; + expect(hasMissingRequiredFields(wide, { mixed: null })).toBe(false); + }); + it("rejects null for a required field the schema does not describe", () => { const undescribed: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 2cdb2331..093adbc9 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -1,4 +1,7 @@ -import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { + admitsNull, + normalizeNullableUnion, +} from "@inspector/core/json/nullableUnion.js"; export type JsonValue = | string @@ -147,6 +150,12 @@ export function collectSchemaDefaults( * since #1928 the user can produce exactly that by clearing a nullable enum. * Treating it as missing would leave the form's submit permanently disabled on * a value the schema calls valid. + * + * The test is `admitsNull`, **not** whether the renderer's collapse recognized + * the field. Those differ: the collapse only handles a two-member union, so a + * three-member `anyOf: [string, number, null]` renders through the JSON + * fallback — where a user can still type `null` — while plainly admitting it. + * Gating on the collapse would reject a value the schema accepts. */ export function hasMissingRequiredFields( schema: InspectorFormSchema, @@ -158,9 +167,7 @@ export function hasMissingRequiredFields( const value = values[field]; if (value === null) { const fieldSchema = properties[field]; - return fieldSchema === undefined - ? true - : normalizeNullableUnion(fieldSchema).nullable !== true; + return fieldSchema === undefined ? true : !admitsNull(fieldSchema); } return value === undefined || value === ""; }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index ae26b042..e17b8508 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -42,6 +42,10 @@ export interface NullableUnionSchema { type?: string | string[]; enum?: unknown[]; anyOf?: readonly unknown[]; + // Read only by `admitsNull`, which considers encodings the collapse itself + // declines to flatten. + oneOf?: readonly unknown[]; + nullable?: boolean; } /** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */ @@ -120,6 +124,46 @@ function collapsed<T extends NullableUnionSchema>( } as NormalizedNullableUnion<T>; } +/** + * Whether a schema permits an explicit `null`. + * + * Deliberately **independent of {@link normalizeNullableUnion}**, which is a + * *renderer* question — "can this become one widget?" — and is therefore + * narrower on purpose: it only collapses a two-member union. Null admission is + * a *validity* question and has no such limit, so + * `anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }]` admits + * null even though it renders through the JSON fallback. Deriving one from the + * other would make a form reject a value its own schema accepts, which is why + * these are two functions rather than a `.nullable` flag read off the collapse. + * + * Recognizes every encoding the collapse does, plus the ones it declines: + * `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"` + * branch anywhere in an `anyOf` or `oneOf` of any size. + */ +export function admitsNull(schema: NullableUnionSchema): boolean { + if (schema.nullable === true) { + return true; + } + if (schema.type === "null") { + return true; + } + if (Array.isArray(schema.type) && schema.type.includes("null")) { + return true; + } + return [schema.anyOf, schema.oneOf].some((branches) => + branches?.some((entry) => { + const branch = toBranch(entry); + if (branch === null) { + return false; + } + return ( + branch.type === "null" || + (Array.isArray(branch.type) && branch.type.includes("null")) + ); + }), + ); +} + /** * Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and * FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`. From 573b14e14e80e831254f52f43aa5b75eabf7995d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:22:10 -0400 Subject: [PATCH 136/941] fix: address Copilot round 4 on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings, both about the fallback accepting more than it should. - The lenient page schema validated pagination and nothing else, so a response carrying a malformed entry AND an unrelated top-level violation salvaged: the entry was reported and the other violation vanished with the strict error that caught it. The page schema is now DERIVED from the method's real result schema with only its item array relaxed (`lenientListPageSchema`), so every other field — `_meta`, `nextCursor`, anything a future revision adds — is still validated. The modern era envelope the raw-wire path bypasses (`resultType`, `ttlMs`, `cacheScope`) is checked explicitly for the same reason. Salvage is also narrowed to `InvalidResult`: `UnsupportedResultType` means the codec doesn't recognize what KIND of frame arrived, so it isn't a list with a bad entry and reporting an item-level reason would hide a protocol failure. Marking the Protocol entry still applies to both, hence two predicates. - On every rethrow path the re-walk had already answered the method, so the caller's own `markResponseRejected` (in `ManagedListState.refresh`) landed on the lenient page that succeeded and left the refused response clean. The correlation is restored before rethrowing — deleted when nothing had answered before, so the outer mark is a no-op rather than a lie. `nextCursorOf` / `rawItemsOf` narrow the page's fields instead of casting: the runtime-keyed `extend()` loses the shape, and asserting it back would be exactly the unjustified cast the last round removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/listSalvage.test.ts | 102 +++++++++++++++--- .../inspectorClient-malformed-list.test.ts | 30 ++++++ core/mcp/inspectorClient.ts | 82 ++++++++++++-- core/mcp/listSalvage.ts | 74 +++++++++++-- 4 files changed, 251 insertions(+), 37 deletions(-) diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts index f0fd7fdc..a117403b 100644 --- a/clients/web/src/test/core/mcp/listSalvage.test.ts +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -2,14 +2,18 @@ import { describe, it, expect } from "vitest"; import { z } from "zod/v4"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { - LenientListPageSchema, + ModernResultEnvelopeSchema, describeIssues, isClientDecodeRejection, + isSalvageableRejection, labelForRawItem, + lenientListPageSchema, + nextCursorOf, rawItemsOf, salvageListItems, summarizeMalformed, } from "@inspector/core/mcp/listSalvage.js"; +import { ListToolsResultSchema } from "@modelcontextprotocol/core"; /** * Per-item list salvage (#1909). @@ -48,34 +52,102 @@ describe("isClientDecodeRejection", () => { }); }); -describe("LenientListPageSchema", () => { - it("keeps the primitive array unvalidated while typing the cursor", () => { - const parsed = LenientListPageSchema.parse({ - resourceTemplates: [{ name: "ok" }, { name: 42 }], +describe("lenientListPageSchema", () => { + const ToolsPageSchema = lenientListPageSchema(ListToolsResultSchema, "tools"); + + it("leaves the entries raw while still validating every other field", () => { + // The entries are this module's job to check one at a time; everything + // else is the real schema's, so a second violation can't ride along. + const parsed = ToolsPageSchema.parse({ + tools: [{ name: "ok" }, { name: 42 }], nextCursor: "page-2", }); - expect(parsed.nextCursor).toBe("page-2"); - expect(rawItemsOf(parsed, "resourceTemplates")).toHaveLength(2); + expect(nextCursorOf(parsed)).toBe("page-2"); + expect(rawItemsOf(parsed, "tools")).toHaveLength(2); }); it("rejects a page whose cursor is malformed", () => { // The walk itself depends on `nextCursor`, so a bad one is not salvageable // — the strict error should stand rather than a partial list being kept. expect( - LenientListPageSchema.safeParse({ tools: [], nextCursor: 7 }).success, + ToolsPageSchema.safeParse({ tools: [], nextCursor: 7 }).success, + ).toBe(false); + }); + + it("rejects a top-level violation that rides along with a bad entry", () => { + // A hand-rolled `{ nextCursor? }` schema would accept this, report the bad + // entry, and silently drop the `_meta` violation with the strict error. + expect( + ToolsPageSchema.safeParse({ tools: [{ name: 42 }], _meta: "nope" }) + .success, ).toBe(false); }); it("distinguishes an empty page from one that is not a list at all", () => { - // Reading either as "no entries" would let a top-level violation pass as a - // silently truncated list. - expect( - rawItemsOf(LenientListPageSchema.parse({ tools: [] }), "tools"), - ).toEqual([]); + expect(rawItemsOf(ToolsPageSchema.parse({ tools: [] }), "tools")).toEqual( + [], + ); expect(rawItemsOf({}, "tools")).toBeUndefined(); + expect(rawItemsOf({ tools: "not-a-list" }, "tools")).toBeUndefined(); + expect(rawItemsOf(null, "tools")).toBeUndefined(); + }); + + it("reads no cursor from a non-object page", () => { + expect(nextCursorOf(null)).toBeUndefined(); + expect(nextCursorOf({ nextCursor: 7 })).toBeUndefined(); + }); +}); + +describe("isSalvageableRejection", () => { + it("salvages an invalid result but not an unrecognized result type", () => { + // `UnsupportedResultType` means the codec doesn't know what KIND of frame + // arrived — not a list with a bad entry, so there is nothing to salvage + // and reporting an item-level reason would hide the real failure. expect( - rawItemsOf(LenientListPageSchema.parse({ tools: "not-a-list" }), "tools"), - ).toBeUndefined(); + isSalvageableRejection(new SdkError(SdkErrorCode.InvalidResult, "bad")), + ).toBe(true); + expect( + isSalvageableRejection( + new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"), + ), + ).toBe(false); + // Still marks the Protocol entry, which is why both predicates exist. + expect( + isClientDecodeRejection( + new SdkError(SdkErrorCode.UnsupportedResultType, "unknown"), + ), + ).toBe(true); + }); +}); + +describe("ModernResultEnvelopeSchema", () => { + it("accepts a complete modern result with its cache hints", () => { + expect( + ModernResultEnvelopeSchema.safeParse({ + resultType: "complete", + ttlMs: 0, + cacheScope: "public", + tools: [], + }).success, + ).toBe(true); + }); + + it("rejects a modern result missing the envelope the codec would enforce", () => { + // The raw-wire salvage path goes around that codec, so a response missing + // its cache hints AND carrying a bad entry must not salvage. + expect( + ModernResultEnvelopeSchema.safeParse({ + resultType: "complete", + tools: [], + }).success, + ).toBe(false); + expect( + ModernResultEnvelopeSchema.safeParse({ + resultType: "task", + ttlMs: 0, + cacheScope: "public", + }).success, + ).toBe(false); }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index e562d6f6..b9b1f8a6 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -565,6 +565,36 @@ describe("InspectorClient list salvage (#1909)", () => { expect(reported[0]?.label).toBeUndefined(); }); + it("leaves the correlation on the original response when it rethrows", async () => { + // On a rethrow the re-walk has already answered the same method, so the + // caller's own `markResponseRejected` would land on the lenient page that + // succeeded. The correlation is restored so the outer mark still finds the + // response that was actually refused. + const server = await startMalformedServer({ + resourceTemplates: [{ items: [VALID_TEMPLATE], nextCursor: 42 }], + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + const log = new MessageLogState(connected); + + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + // Simulates what ManagedListState.refresh() does in its catch. + connected.markResponseRejected("resources/templates/list", "outer mark"); + + const calls = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "resources/templates/list", + ); + expect(calls[0]?.clientError).toBe("outer mark"); + for (const later of calls.slice(1)) { + expect(later.clientError).toBeUndefined(); + } + log.destroy(); + }); + it("rethrows when the rejection is not about any single entry", async () => { // Every entry validates; the result is bad for another reason (a cursor of // the wrong type). There is no per-item story, so the original error must diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index df03a016..90a6c37d 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -172,8 +172,10 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; import { - LenientListPageSchema, - isClientDecodeRejection, + ModernResultEnvelopeSchema, + isSalvageableRejection, + nextCursorOf, + lenientListPageSchema, rawItemsOf, salvageListItems, summarizeMalformed, @@ -3187,6 +3189,7 @@ export class InspectorClient extends InspectorClientEventTarget { const tools = await this.listAllSalvaging({ method: "tools/list", itemsKey: "tools", + resultSchema: ListToolsResultSchema, itemSchema: ToolSchema, metadata: options?.metadata, finalize: (salvaged) => this.excludeInvalidXMcpHeaderTools(salvaged), @@ -3254,6 +3257,27 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("responseRejected", { id, reason }); } + /** + * Put the method's response correlation back where it was before a salvage + * re-walk, so a caller that goes on to mark the failure still marks the + * ORIGINAL rejected response. + * + * The re-walk answers the same method, so on any rethrow path the map points + * at a lenient page that succeeded. `ManagedListState.refresh()` catches the + * rethrown error and calls `markResponseRejected(method, ...)`, which would + * then stamp "Rejected by the Inspector" onto that page and leave the real + * one rendering clean — the same misattribution the capture inside + * `salvageList` exists to prevent, just one frame further out. Deleting when + * nothing had answered before makes the outer mark a no-op rather than a lie. + */ + private restoreResponseCorrelation( + method: string, + id: string | number | undefined, + ): void { + if (id === undefined) this.lastAnsweredRequestByMethod.delete(method); + else this.lastAnsweredRequestByMethod.set(method, id); + } + /** Every entry dropped from a list result as malformed, across methods (#1909). */ getMalformedListItems(): MalformedListItem[] { return [...this.malformedListItems.values()].flat(); @@ -3295,6 +3319,7 @@ export class InspectorClient extends InspectorClientEventTarget { method, itemsKey, itemSchema, + resultSchema, aggregate, metadata, finalize, @@ -3302,6 +3327,9 @@ export class InspectorClient extends InspectorClientEventTarget { method: string; itemsKey: string; itemSchema: z.ZodType<T>; + /** The method's real result schema — the salvage page schema is derived + * from it so every field except the entries is still validated. */ + resultSchema: z.ZodObject; aggregate: () => Promise<T[]>; metadata?: Record<string, string>; /** @@ -3316,11 +3344,15 @@ export class InspectorClient extends InspectorClientEventTarget { this.setMalformedListItems(method, []); return items; } catch (err) { - if (!isClientDecodeRejection(err)) throw err; + // Deliberately narrower than the Protocol-marking predicate — see + // `isSalvageableRejection`: an unrecognized result KIND is not a list + // with a bad entry in it. + if (!isSalvageableRejection(err)) throw err; const salvaged = await this.salvageList({ method, itemsKey, itemSchema, + resultSchema, metadata, err, }); @@ -3369,15 +3401,18 @@ export class InspectorClient extends InspectorClientEventTarget { method, itemsKey, itemSchema, + resultSchema, metadata, err, }: { method: string; itemsKey: string; itemSchema: z.ZodType<T>; + resultSchema: z.ZodObject; metadata?: Record<string, string>; err: unknown; }): Promise<T[]> { + const pageSchema = lenientListPageSchema(resultSchema, itemsKey); // Capture the correlation id BEFORE re-walking. `markResponseRejected` // resolves "the last response received for this method", and the re-walk // below answers that same method one or more times — so marking afterwards @@ -3413,15 +3448,23 @@ export class InspectorClient extends InspectorClientEventTarget { ? this.rawWireRequest( method, this.withModernEnvelope(params), - LenientListPageSchema, + pageSchema, ) : this.client!.request( { method, params }, - LenientListPageSchema, + pageSchema, this.getRequestOptions(metadata?.progressToken), ), { method }, ); + // The raw-wire path skips the era codec, so the envelope it would have + // enforced is checked here; a violation is not salvageable. + if ( + this.isModernEra() && + !ModernResultEnvelopeSchema.safeParse(page).success + ) { + throw err; + } const items = rawItemsOf(page, itemsKey); // The page's list member is missing or isn't a list: a top-level // violation, not a per-item one. Reading it as "no entries" would return @@ -3436,7 +3479,7 @@ export class InspectorClient extends InspectorClientEventTarget { }); valid.push(...salvaged.valid); malformed.push(...salvaged.malformed); - cursor = page.nextCursor; + cursor = nextCursorOf(page); if (cursor !== undefined) { /* v8 ignore next -- defensive: a spec-compliant server never repeats a cursor; this guards a non-converging server from an infinite walk (mirrors the SDK's drainList guard) */ if (seenCursors.has(cursor)) break; @@ -3456,12 +3499,16 @@ export class InspectorClient extends InspectorClientEventTarget { "Salvage re-walk failed; surfacing the original list error", ); } + this.restoreResponseCorrelation(method, rejectedResponseId); throw err; } // Nothing per-item was wrong, so the strict rejection was about something // this fallback cannot explain — surface it rather than hide it. - if (malformed.length === 0) throw err; + if (malformed.length === 0) { + this.restoreResponseCorrelation(method, rejectedResponseId); + throw err; + } if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { @@ -3568,7 +3615,7 @@ export class InspectorClient extends InspectorClientEventTarget { const page = await this.listTools(cursor, metadata); return { ...page, malformed: [] }; } catch (err) { - if (!isClientDecodeRejection(err)) throw err; + if (!isSalvageableRejection(err)) throw err; // Capture before the re-fetch, for the reason `salvageList` documents: // this refused response is a distinct exchange from the aggregate's, and // the re-fetch below moves the method's correlation off it. Without this @@ -3582,17 +3629,26 @@ export class InspectorClient extends InspectorClientEventTarget { // See `salvageList`: "" is a valid cursor. ...(cursor !== undefined && { cursor }), }; + const pageSchema = lenientListPageSchema(ListToolsResultSchema, "tools"); const page = this.isModernEra() ? await this.rawWireRequest( "tools/list", this.withModernEnvelope(params), - LenientListPageSchema, + pageSchema, ) : await this.client!.request( { method: "tools/list", params }, - LenientListPageSchema, + pageSchema, this.getRequestOptions(metadata?.progressToken), ); + // Same era-envelope check as `salvageList` — the raw-wire path bypasses + // the codec that would otherwise enforce it. + if ( + this.isModernEra() && + !ModernResultEnvelopeSchema.safeParse(page).success + ) { + throw err; + } const items = rawItemsOf(page, "tools"); // Not a list at all — a top-level violation the per-item pass can't // explain, so the strict error stands (mirrors `salvageList`). @@ -3608,9 +3664,10 @@ export class InspectorClient extends InspectorClientEventTarget { reason: summarizeMalformed(malformed), }); } + const nextCursor = nextCursorOf(page); return { tools: valid, - ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor }), + ...(nextCursor !== undefined && { nextCursor }), malformed, }; } @@ -4737,6 +4794,7 @@ export class InspectorClient extends InspectorClientEventTarget { const resources = await this.listAllSalvaging({ method: "resources/list", itemsKey: "resources", + resultSchema: ListResourcesResultSchema, itemSchema: ResourceSchema, metadata: options?.metadata, aggregate: async () => [ @@ -4902,6 +4960,7 @@ export class InspectorClient extends InspectorClientEventTarget { const resourceTemplates = await this.listAllSalvaging({ method: "resources/templates/list", itemsKey: "resourceTemplates", + resultSchema: ListResourceTemplatesResultSchema, itemSchema: ResourceTemplateSchema, metadata: options?.metadata, aggregate: async () => [ @@ -4963,6 +5022,7 @@ export class InspectorClient extends InspectorClientEventTarget { const prompts = await this.listAllSalvaging({ method: "prompts/list", itemsKey: "prompts", + resultSchema: ListPromptsResultSchema, itemSchema: PromptSchema, metadata: options?.metadata, aggregate: async () => [ diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 5d12f5da..37aeebbe 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -79,19 +79,70 @@ export function isClientDecodeRejection(err: unknown): boolean { } /** - * A list page parsed with everything except pagination left unvalidated. + * Whether a decode rejection is one the per-item fallback may attempt at all. * - * `looseObject` keeps the unknown keys, so the primitive array (`tools`, - * `resources`, `resourceTemplates`, `prompts`) survives as `unknown` for - * per-item validation. Only `nextCursor` is typed, because the walk itself - * depends on it — if THAT is malformed the page is unusable and the strict - * error should stand. + * Narrower than {@link isClientDecodeRejection} on purpose, and the difference + * is the point: `UnsupportedResultType` says the codec does not recognize what + * KIND of frame arrived, so it is not a list result with a bad entry in it — + * it is not a list result at all. Salvaging it would report an item-level + * reason for a protocol-level failure and hide the real one. Marking the + * Protocol entry rejected still applies to both, which is why the broader + * predicate stays. */ -export const LenientListPageSchema = z.looseObject({ - nextCursor: z.string().optional(), -}); +export function isSalvageableRejection(err: unknown): boolean { + return SdkError.isInstance(err) && err.code === SdkErrorCode.InvalidResult; +} -export type LenientListPage = z.infer<typeof LenientListPageSchema>; +/** + * The page schema for a salvage re-walk: the method's REAL result schema with + * only its item array relaxed to `unknown[]`. + * + * Deriving it from the spec schema rather than hand-rolling a permissive object + * is what keeps the fallback honest. A hand-rolled `{ nextCursor? }` validates + * pagination and nothing else, so a response carrying a malformed item AND an + * unrelated top-level violation would be accepted, the item reported, and the + * other violation silently dropped along with the strict error that caught it. + * Extending the real schema means every top-level field — `_meta`, `nextCursor`, + * anything a future revision adds — is still validated; only the entries, which + * this whole module exists to check one at a time, are left raw. + */ +export function lenientListPageSchema( + resultSchema: z.ZodObject, + itemsKey: string, +): z.ZodObject { + return resultSchema.extend({ [itemsKey]: z.array(z.unknown()) }); +} + +/** + * Read the pagination cursor off a leniently-parsed page. + * + * The page is typed as a plain object because `extend()` on a runtime-selected + * key loses the shape, so the fields are read through narrowing helpers rather + * than asserted with a cast. The schema has already validated that a present + * `nextCursor` is a string; this narrows the static type to match. + */ +export function nextCursorOf(page: unknown): string | undefined { + if (typeof page !== "object" || page === null) return undefined; + const value = (page as Record<string, unknown>).nextCursor; + return typeof value === "string" ? value : undefined; +} + +/** + * The era envelope a modern (2026-07-28) result must carry, checked separately + * because it lives in the SDK's codec rather than in the spec result schema — + * and the modern salvage path deliberately goes around that codec. + * + * Without this, a modern response that is missing its cache hints AND has one + * bad entry would salvage: the entry gets reported and the envelope violation + * disappears. A failure here means "not salvageable", so the strict error + * stands — the safe direction if a future revision changes these rules, since + * the strict path remains the authority on what is valid. + */ +export const ModernResultEnvelopeSchema = z.looseObject({ + resultType: z.literal("complete"), + ttlMs: z.number(), + cacheScope: z.enum(["public", "private"]), +}); /** * Read the primitive array out of a leniently-parsed page. @@ -104,9 +155,10 @@ export type LenientListPage = z.infer<typeof LenientListPageSchema>; * that was correct about it is discarded. */ export function rawItemsOf( - page: LenientListPage, + page: unknown, itemsKey: string, ): unknown[] | undefined { + if (typeof page !== "object" || page === null) return undefined; const value = (page as Record<string, unknown>)[itemsKey]; return Array.isArray(value) ? value : undefined; } From 8356033d58b29c219c256f701e92d5b3c57a4832 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:28:38 -0400 Subject: [PATCH 137/941] fix: strip the enum null sentinel, and make the preset test cover its wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 4: no new visible comments, two in the suppressed block. Both valid. 1. The `type: [T, "null"]` encoding keeps its keywords at the top level, so a nullable enum written that way carries the null *inside* the list: `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The collapse left it there, which broke both renderers in different ways — the web dispatcher handed `null` to Mantine as option data, and the TUI's all-strings check rejected the whole enum and fell back to a plain text field. Neither is the dropdown the schema asks for. The collapse now strips a `null` member whenever it flattens, since that fact has already moved onto `nullable`. An enum of nothing but `null` drops out entirely: it offers no value a dropdown could show, so the plain widget serves the field better than an empty select. Covered in core and end-to-end in both dispatchers. 2. The new integration test claimed in its own header to pin the registry and config wiring, then called `createNullableFieldsTool()` directly — so a misspelt preset case or a config naming a dead preset would have left it green. A fair "this does not test what it says" catch. It now resolves the checked-in `nullable-fields-http.json` through `loadConfig` → `resolveConfig`, matching `duplicate-tool-names.test.ts`, and asserts the resolved tool names before booting the server. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/tui/__tests__/schemaToForm.test.ts | 23 ++++++ .../groups/SchemaForm/SchemaForm.test.tsx | 28 +++++++ .../web/src/test/core/nullableUnion.test.ts | 58 +++++++++++++++ .../integration/mcp/nullable-fields.test.ts | 74 +++++++++++++------ core/json/nullableUnion.ts | 33 ++++++++- 5 files changed, 190 insertions(+), 26 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 769c3026..f47450e3 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -293,6 +293,29 @@ describe("schemaToForm", () => { }); }); + // With the null left in the list, the all-strings check would reject the + // enum and this would degrade to a plain text field. + it("renders a select for a type: [string, null] enum, null stripped", () => { + const form = schemaToForm( + { + properties: { + direction: { + type: ["string", "null"], + enum: ["envio", "recebimento", null], + }, + }, + }, + "typeArrayNullableEnum", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + options: [ + { label: "envio", value: "envio" }, + { label: "recebimento", value: "recebimento" }, + ], + }); + }); + it("collapses the type: [T, null] encoding too", () => { const form = schemaToForm( { diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 287da67b..0110bf00 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -886,6 +886,34 @@ describe("SchemaForm nullable unions", () => { ).not.toBeInTheDocument(); }); + // The other supported nullable encoding: keywords stay at the top level, so + // the null sentinel sits inside the enum list rather than on a branch. + it("renders a Select for a type: [string, null] enum, without a null option", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + direction: { + title: "Direction", + type: ["string", "null"], + enum: ["envio", "recebimento", null] as unknown as string[], + }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={onChange} />, + ); + await user.click(screen.getByRole("textbox", { name: "Direction" })); + const options = await screen.findAllByRole("option", { hidden: true }); + expect(options.map((option) => option.textContent)).toEqual([ + "envio", + "recebimento", + ]); + await user.click(options[0]); + expect(onChange).toHaveBeenCalledWith({ direction: "envio" }); + }); + it("renders a TextInput for a type: [string, null] field", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 485febf1..2bf7a1c9 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -246,6 +246,64 @@ describe("normalizeNullableUnion", () => { }); }); + // The `type: [T, "null"]` encoding keeps its keywords at the top level, so a + // nullable enum written that way carries the null *inside* the list. Leaving + // it there hands `null` to Mantine as option data, and makes the TUI's + // all-strings check reject the enum and fall back to a text field. + it("strips the null sentinel from a type-array nullable enum", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: ["envio", "recebimento", null], + }), + ).toEqual({ + type: "string", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: true, + }); + }); + + it("strips a null sentinel hoisted off an anyOf branch too", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "string", enum: ["a", null] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a"], + anyOf: undefined, + nullable: true, + }); + }); + + it("drops an enum that held nothing but null", () => { + // No value a dropdown could offer, so the field is better served by its + // plain widget than by an empty select. + expect( + normalizeNullableUnion({ type: ["string", "null"], enum: [null] }), + ).toEqual({ + type: "string", + enum: undefined, + anyOf: undefined, + nullable: true, + }); + }); + + it("leaves a null-free enum untouched", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: ["a", "b"], + }), + ).toEqual({ + type: "string", + enum: ["a", "b"], + anyOf: undefined, + nullable: true, + }); + }); + it("normalizes type array array|null", () => { expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ type: "array", diff --git a/clients/web/src/test/integration/mcp/nullable-fields.test.ts b/clients/web/src/test/integration/mcp/nullable-fields.test.ts index 0a54e142..6396a85b 100644 --- a/clients/web/src/test/integration/mcp/nullable-fields.test.ts +++ b/clients/web/src/test/integration/mcp/nullable-fields.test.ts @@ -1,35 +1,47 @@ import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; import { toFormSchema } from "../../../utils/jsonUtils"; +import type { InspectorFormSchema } from "../../../utils/jsonUtils"; import { createTestServerHttp, type TestServerHttp, createTestServerInfo, - createNullableFieldsTool, + loadConfig, + resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; /** - * Live coverage of the `record_shipment` preset behind - * `test-servers/configs/nullable-fields-http.json` — the documented manual - * reproduction for #1928. + * Live coverage of `test-servers/configs/nullable-fields-http.json` — the + * documented manual reproduction for #1928. * * The unit tests on both form builders construct the `anyOf` shape by hand, * which verifies the *renderers* but assumes the premise: that Zod's * `.nullish()` actually emits `anyOf: [<branch>, { type: "null" }]` with the * `enum` on the branch. That premise is the whole reason the fix exists, and - * nothing else pins it — a Zod change in how it compiles a nullish enum, or a - * typo in the preset registry, would leave every unit test green while the - * showcase server quietly stopped reproducing the bug. + * nothing else pins it — a Zod change in how it compiles a nullish enum would + * leave every unit test green while the showcase server quietly stopped + * reproducing the bug. * - * So this asserts the wire shape end to end, then runs it through the same - * collapse the forms use. + * So the server is built by **resolving the checked-in config**, not by calling + * the fixture factory directly. That is deliberate and is the difference + * between covering the wiring and merely asserting the factory: going through + * `loadConfig` → `resolveConfig` means a misspelt preset name in + * `preset-registry.ts`, or a config that names a preset that no longer exists, + * fails here rather than only when someone runs the repro by hand. */ describe("nullable argument schemas over the wire (#1928)", () => { let client: InspectorClient | null = null; let server: TestServerHttp | null = null; + const configPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../../test-servers/configs/nullable-fields-http.json", + ); + afterEach(async () => { if (client) { try { @@ -49,10 +61,16 @@ describe("nullable argument schemas over the wire (#1928)", () => { } }); - async function connectToPreset(): Promise<InspectorClient> { + /** + * Boot the showcase config. The harness picks the port rather than using the + * config's fixed one, so this cannot collide with a showcase server someone + * is running by hand. + */ + async function connectToShowcase(): Promise<InspectorClient> { + const resolved = resolveConfig(loadConfig(configPath)); const started = createTestServerHttp({ serverInfo: createTestServerInfo("nullable-fields-test", "1.0.0"), - tools: [createNullableFieldsTool()], + tools: resolved.tools, }); await started.start(); server = started; @@ -66,15 +84,29 @@ describe("nullable argument schemas over the wire (#1928)", () => { return connected; } - it("emits an anyOf-with-null for every nullish argument, enum on the branch", async () => { - const connected = await connectToPreset(); + async function recordShipmentSchema(): Promise<InspectorFormSchema> { + const connected = await connectToShowcase(); const { tools } = await connected.listAllTools(); - const tool = tools.find((entry) => entry.name === "record_shipment"); expect(tool).toBeDefined(); - const schema = toFormSchema(tool?.inputSchema); - const properties = schema?.properties ?? {}; + expect(schema).not.toBeNull(); + return schema as InspectorFormSchema; + } + + it("resolves the preset the config names", () => { + // Fails on a misspelt registry case or a config naming a dead preset — + // neither of which the hand-built unit schemas can see. + const resolved = resolveConfig(loadConfig(configPath)); + expect(resolved.tools?.map((tool) => tool.name)).toEqual([ + "record_shipment", + "get_temp", + ]); + }); + + it("emits an anyOf-with-null for every nullish argument, enum on the branch", async () => { + const schema = await recordShipmentSchema(); + const properties = schema.properties ?? {}; expect(Object.keys(properties).sort()).toEqual([ "direction", "express", @@ -94,15 +126,9 @@ describe("nullable argument schemas over the wire (#1928)", () => { }); it("collapses each argument to the type its widget dispatches on", async () => { - const connected = await connectToPreset(); - const { tools } = await connected.listAllTools(); - const schema = toFormSchema( - tools.find((entry) => entry.name === "record_shipment")?.inputSchema, - ); - const properties = schema?.properties ?? {}; - + const schema = await recordShipmentSchema(); const collapsed = Object.fromEntries( - Object.entries(properties).map(([name, propertySchema]) => [ + Object.entries(schema.properties ?? {}).map(([name, propertySchema]) => [ name, normalizeNullableUnion(propertySchema), ]), diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index e17b8508..edcee0c4 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -101,6 +101,30 @@ export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit< nullable?: boolean; }; +/** + * Drop a `null` sentinel from an `enum`, since the collapse has already moved + * that fact onto `nullable`. + * + * The `type: [T, "null"]` encoding keeps its keywords at the top level, so a + * nullable enum written that way carries the null *inside* the list: + * `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. Leaving + * it there breaks both renderers in different ways — the web dispatcher would + * hand `null` to Mantine as option data, and the TUI's all-strings check would + * reject the whole enum and fall back to a plain text field. Neither is the + * dropdown the schema is asking for. + * + * Returns `undefined` when nothing but `null` was in the list: an enum of only + * `null` permits no value a dropdown could offer, so the field is better served + * by its plain widget than by an empty select. + */ +function stripNullFromEnum(value: unknown): unknown[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const members = value.filter((member) => member !== null); + return members.length > 0 ? members : undefined; +} + /** * Build the collapsed schema. * @@ -115,12 +139,17 @@ function collapsed<T extends NullableUnionSchema>( branch: Record<string, unknown> | undefined, type: RenderableType, ): NormalizedNullableUnion<T> { + const merged = { ...schema, ...branch }; return { - ...schema, - ...branch, + ...merged, type, anyOf: undefined, nullable: true, + // `enum` is read after the spread so the branch's list wins when it has + // one, and the null sentinel is stripped either way. + ...(merged.enum === undefined + ? {} + : { enum: stripNullFromEnum(merged.enum) }), } as NormalizedNullableUnion<T>; } From 07cafeedbcc28a6f07b12d5d0958398f9d4c6cc9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:33:41 -0400 Subject: [PATCH 138/941] feat: surface and decode the OAuth id_token in Connection Info (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an authorization server returns an `id_token` alongside the access token, the Inspector already persisted it and never showed it. Wire it through to Connection Info as a viewer. - Generalize `OAuthAccessTokenField` into `OAuthTokenField` (label + token), so the ID Token row reuses the existing copy control and "Decode JWT" toggle rather than duplicating them. Access-token behavior is unchanged. - `oauthDetailsFromConnectionState` now surfaces `tokens.id_token` as `idToken`, omitted entirely when absent so no empty row renders. - Docs: update the two `specification/` references to the renamed field. This is deliberately a viewer, not a flow: no OIDC login mode, no signature verification, and the `id_token` is never treated as a credential. The EMA path is untouched. `id_token` is already in `maskSecrets`' `JSON_SENSITIVE_KEYS`, so it stays masked in the Network tab — no gap. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ConnectionInfoContent.stories.tsx | 34 +++++++++++- .../ConnectionInfoContent.test.tsx | 54 +++++++++++++++++++ .../ConnectionInfoContent.tsx | 38 ++++++++----- ...ield.test.tsx => OAuthTokenField.test.tsx} | 44 +++++++++++---- ...cessTokenField.tsx => OAuthTokenField.tsx} | 35 +++++++----- .../oauthDetailsFromConnectionState.test.ts | 33 ++++++++++++ .../oauthDetailsFromConnectionState.ts | 5 ++ specification/v2_auth_ema.md | 2 +- specification/v2_ux.md | 2 +- 9 files changed, 211 insertions(+), 36 deletions(-) rename clients/web/src/components/groups/ConnectionInfoContent/{OAuthAccessTokenField.test.tsx => OAuthTokenField.test.tsx} (66%) rename clients/web/src/components/groups/ConnectionInfoContent/{OAuthAccessTokenField.tsx => OAuthTokenField.tsx} (74%) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 3b1c919b..a634b9de 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -3,7 +3,7 @@ import type { InitializeResult, } from "@modelcontextprotocol/client"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { ConnectionInfoContent, SERVER_INFO_NOT_REPORTED_LABEL, @@ -161,3 +161,35 @@ export const WithOAuth: Story = { }, }, }; + +// An authorization server that also returned an `id_token` (#2019). Shown for +// inspection only — decoding it is display-only, and it is never used as a +// credential. +export const WithOAuthIdToken: Story = { + args: { + initializeResult: { + protocolVersion: "2025-03-26", + serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" }, + capabilities: { tools: { listChanged: true } }, + }, + clientCapabilities: { roots: { listChanged: true } }, + transport: "streamable-http", + oauth: { + protocol: "standard", + authorized: true, + authUrl: "https://auth.example.com/oauth2/authorize", + scopes: ["openid", "email", "read"], + accessToken: "opaque-access-token", + // header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"} + idToken: + "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("ID Token")).toBeInTheDocument(); + // Only the ID token is a JWT here, so exactly one decode toggle is offered. + await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" })); + expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 9f9a8ea4..e33c63ad 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -473,6 +473,60 @@ describe("ConnectionInfoContent", () => { expect(screen.queryByText("Auth URL")).not.toBeInTheDocument(); expect(screen.queryByText("Scopes")).not.toBeInTheDocument(); expect(screen.queryByText("Access Token")).not.toBeInTheDocument(); + expect(screen.queryByText("ID Token")).not.toBeInTheDocument(); + }); + + it("renders the ID Token row when the token set carries one", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={fullResult} + clientCapabilities={fullClientCaps} + transport="streamable-http" + oauth={{ + protocol: "standard", + authorized: true, + accessToken: "token-123", + idToken: "id-token-456", + }} + />, + ); + expect(screen.getByText("Access Token")).toBeInTheDocument(); + expect(screen.getByText("ID Token")).toBeInTheDocument(); + expect(screen.getByText("id-token-456")).toBeInTheDocument(); + }); + + it("renders the ID Token row on its own when there is no access token", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={fullResult} + clientCapabilities={fullClientCaps} + transport="streamable-http" + oauth={{ + protocol: "standard", + authorized: true, + idToken: "id-token-456", + }} + />, + ); + expect(screen.queryByText("Access Token")).not.toBeInTheDocument(); + expect(screen.getByText("ID Token")).toBeInTheDocument(); + }); + + it("omits the ID Token row when only an access token is present", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={fullResult} + clientCapabilities={fullClientCaps} + transport="streamable-http" + oauth={{ + protocol: "standard", + authorized: true, + accessToken: "token-123", + }} + />, + ); + expect(screen.getByText("Access Token")).toBeInTheDocument(); + expect(screen.queryByText("ID Token")).not.toBeInTheDocument(); }); it("does not render OAuth section when oauth prop is omitted", () => { diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index a7cd53f2..dde264b6 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -24,7 +24,7 @@ import { import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { EraBadge } from "../../elements/EraBadge/EraBadge"; import { isModernEra } from "../../elements/EraBadge/eraUtils"; -import { OAuthAccessTokenField } from "./OAuthAccessTokenField"; +import { OAuthTokenField } from "./OAuthTokenField"; export interface OAuthDetails { protocol: "standard" | "ema"; @@ -34,6 +34,13 @@ export interface OAuthDetails { authUrl?: string; scopes?: string[]; accessToken?: string; + /** + * OIDC `id_token` from the stored token set, when the authorization server + * returned one. Shown for inspection only — the MCP authorization spec is + * plain OAuth 2.1 and the Inspector never treats this as a credential + * (#2019). Absent when the token set carries none, so no empty row renders. + */ + idToken?: string; /** EMA only — install-level IdP session for legs 1–2. */ idpSession?: "none" | "logged_in" | "expired"; } @@ -370,20 +377,27 @@ export function ConnectionInfoContent({ <ValueText>{formatScopes(oauth.scopes)}</ValueText> </SimpleGrid> )} - {oauth.accessToken ? ( - <OAuthAccessTokenField - accessToken={oauth.accessToken} + {oauth.accessToken && ( + <OAuthTokenField + label="Access Token" + token={oauth.accessToken} onClear={onClearOAuth} clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} /> - ) : ( - onClearOAuth && ( - <Flex justify="flex-end"> - <ClearOAuthButton onClick={onClearOAuth}> - {CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} - </ClearOAuthButton> - </Flex> - ) + )} + {/* Viewer only — an `id_token` the AS happened to return, decoded + on request. It carries no clear action: clearing OAuth state is + one action for the whole token set, owned by the access-token + row (or the standalone button below when there is none). */} + {oauth.idToken && ( + <OAuthTokenField label="ID Token" token={oauth.idToken} /> + )} + {!oauth.accessToken && onClearOAuth && ( + <Flex justify="flex-end"> + <ClearOAuthButton onClick={onClearOAuth}> + {CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} + </ClearOAuthButton> + </Flex> )} </Stack> </Stack> diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx similarity index 66% rename from clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx rename to clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx index f1380f12..188c05a7 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx @@ -2,13 +2,13 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent"; -import { OAuthAccessTokenField } from "./OAuthAccessTokenField"; +import { OAuthTokenField } from "./OAuthTokenField"; const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."; -describe("OAuthAccessTokenField", () => { +describe("OAuthTokenField", () => { it("renders token with copy control beside the content", () => { - renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />); + renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); expect(screen.getByText("Access Token")).toBeInTheDocument(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument(); @@ -17,7 +17,7 @@ describe("OAuthAccessTokenField", () => { it("replaces raw token with decoded JSON and restores on toggle", async () => { const user = userEvent.setup(); - renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />); + renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Decode JWT" })); @@ -31,7 +31,10 @@ describe("OAuthAccessTokenField", () => { it("omits decode toggle for opaque tokens", () => { renderWithMantine( - <OAuthAccessTokenField accessToken="opaque-access-token-value" />, + <OAuthTokenField + label="Access Token" + token="opaque-access-token-value" + />, ); expect( screen.queryByRole("button", { name: "Decode JWT" }), @@ -46,7 +49,7 @@ describe("OAuthAccessTokenField", () => { configurable: true, }); - renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />); + renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); await user.click(screen.getByRole("button", { name: "Decode JWT" })); await user.click(screen.getByRole("button", { name: "Copy" })); @@ -64,7 +67,7 @@ describe("OAuthAccessTokenField", () => { configurable: true, }); - renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />); + renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); await user.click(screen.getByRole("button", { name: "Copy" })); expect(writeText).toHaveBeenCalledWith(jwt); @@ -74,8 +77,9 @@ describe("OAuthAccessTokenField", () => { const user = userEvent.setup(); const onClear = vi.fn(); renderWithMantine( - <OAuthAccessTokenField - accessToken="opaque-access-token-value" + <OAuthTokenField + label="Access Token" + token="opaque-access-token-value" onClear={onClear} clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL} />, @@ -87,4 +91,26 @@ describe("OAuthAccessTokenField", () => { ); expect(onClear).toHaveBeenCalledTimes(1); }); + + it("labels the row from the label prop and decodes an ID token", async () => { + const user = userEvent.setup(); + // header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns + // for an `id_token`; the decode path is identical to the access token's. + renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />); + + expect(screen.getByText("ID Token")).toBeInTheDocument(); + expect(screen.queryByText("Access Token")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Decode JWT" })); + expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); + }); + + it("omits the clear action when no handler is given", () => { + renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />); + expect( + screen.queryByRole("button", { + name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL, + }), + ).not.toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx similarity index 74% rename from clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx rename to clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx index 825a8507..a9c19b2d 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx @@ -3,8 +3,18 @@ import { Button, Code, Flex, Stack, Text } from "@mantine/core"; import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; -export interface OAuthAccessTokenFieldProps { - accessToken: string; +/** + * One labelled OAuth token row: the raw value, a copy control, and — when the + * value has the three-segment JWT shape — an in-place "Decode JWT" toggle. + * + * Shared by the Access Token and ID Token rows (#2019). The decode is + * display-only (`decodeJwtPayload` does no signature verification), so this is + * a viewer: neither token is treated as a credential here. + */ +export interface OAuthTokenFieldProps { + /** Row caption, e.g. "Access Token" / "ID Token". */ + label: string; + token: string; onClear?: () => void; clearLabel?: string; } @@ -64,16 +74,17 @@ function JwtTokenText({ token }: { token: string }) { ); } -export function OAuthAccessTokenField({ - accessToken, +export function OAuthTokenField({ + label, + token, onClear, clearLabel = "Clear", -}: OAuthAccessTokenFieldProps) { +}: OAuthTokenFieldProps) { const [showDecoded, setShowDecoded] = useState(false); - const isJwt = isJwtFormat(accessToken); + const isJwt = isJwtFormat(token); const jwtDecoded = useMemo( - () => (isJwt ? decodeJwtPayload(accessToken) : undefined), - [accessToken, isJwt], + () => (isJwt ? decodeJwtPayload(token) : undefined), + [token, isJwt], ); const decodedText = useMemo(() => { @@ -85,12 +96,12 @@ export function OAuthAccessTokenField({ ); }, [jwtDecoded]); - const copyValue = showDecoded && decodedText ? decodedText : accessToken; + const copyValue = showDecoded && decodedText ? decodedText : token; return ( <Stack gap="xs"> <CaptionRow> - <Caption>Access Token</Caption> + <Caption>{label}</Caption> <Toolbar> {jwtDecoded && ( <ToolbarButton @@ -113,9 +124,9 @@ export function OAuthAccessTokenField({ {showDecoded && decodedText ? ( decodedText ) : isJwt ? ( - <JwtTokenText token={accessToken} /> + <JwtTokenText token={token} /> ) : ( - accessToken + token )} </TokenCode> </TokenColumn> diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts index d6a89da6..d047ab23 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts @@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => { }); }); + it("surfaces an id_token when the token set carries one", () => { + const state: OAuthConnectionState = { + authorized: true, + protocol: "standard", + serverUrl: "https://mcp.example.com/mcp", + tokens: { + access_token: "tok", + token_type: "Bearer", + id_token: "header.payload.signature", + }, + }; + + expect(oauthDetailsFromConnectionState(state)).toEqual({ + protocol: "standard", + authorized: true, + accessToken: "tok", + idToken: "header.payload.signature", + }); + }); + + it("omits idToken when the token set carries none", () => { + const state: OAuthConnectionState = { + authorized: true, + protocol: "standard", + serverUrl: "https://mcp.example.com/mcp", + tokens: { access_token: "tok", token_type: "Bearer" }, + }; + + expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty( + "idToken", + ); + }); + it("includes EMA idp session when present", () => { const state: OAuthConnectionState = { authorized: false, diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts index 14c8847e..2e514633 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts @@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState( ...(state.tokens?.access_token && { accessToken: state.tokens.access_token, }), + // Surfaced for inspection when the AS returned one; omitted entirely + // otherwise so Connection Info renders no empty row (#2019). + ...(state.tokens?.id_token && { + idToken: state.tokens.id_token, + }), ...(state.protocol === "ema" && state.ema?.idpSession && { idpSession: state.ema.idpSession }), }; diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index a7a4c917..bfdb4358 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -334,7 +334,7 @@ When the user connects to a server with `oauth.enterpriseManaged: true` but inst - **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]` - **Auth URL** — cached from an in-flight or completed OAuth flow (`OAuthFlowState`), when present - **Scopes** — configured vs granted -- **Access token** — `OAuthAccessTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs +- **Access token** (and **ID token**, when the authorization server returned an `id_token`) — `OAuthTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs. Display only — the `id_token` is never treated as a credential (#2019) In-flight OAuth flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()`. `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral. diff --git a/specification/v2_ux.md b/specification/v2_ux.md index 08c37b9b..675e9472 100644 --- a/specification/v2_ux.md +++ b/specification/v2_ux.md @@ -294,7 +294,7 @@ Shown as a modal or dedicated screen after successful connection. - Server capabilities: Tools, Resources, Prompts, Logging, Completions, Tasks, Experimental - Client capabilities: Sampling, Elicitation, Roots, Tasks, Experimental - Display server instructions if provided -- Show OAuth connection snapshot when applicable (`InspectorClient.getOAuthState()` → `OAuthConnectionState`): protocol, authorization status, client id, EMA IdP session, cached auth URL, scopes, access token with copy and in-place JWT decode (`OAuthAccessTokenField`) +- Show OAuth connection snapshot when applicable (`InspectorClient.getOAuthState()` → `OAuthConnectionState`): protocol, authorization status, client id, EMA IdP session, cached auth URL, scopes, and the stored tokens — access token, plus the `id_token` when the authorization server returned one — each with copy and in-place JWT decode (`OAuthTokenField`) ### Feature Screens From 71cbe27d83772a7d28f46b5578ea5c5047b6961d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:46:54 -0400 Subject: [PATCH 139/941] fix: address Copilot round 5 on the list-salvage path (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings are the same shape: the fallback accepting what the strict path would not. - `ModernResultEnvelopeSchema` typed `ttlMs` as any number, where the codec requires a non-negative integer. `ttlMs: -1` (or `0.5`) plus one bad entry would salvage, reporting the entry and hiding the envelope violation. Tightened to `z.int().min(0)`. - The item schema was the SDK's era-NEUTRAL `ToolSchema`, and the negotiated era's wire schema is stricter in ways the SDK does not export. Verified against SDK 2.0.0 on a real connection: on legacy, a tool with `outputSchema: { type: "array" }` is refused by the strict aggregate but accepted by the neutral schema; on modern, `execution` is STRIPPED by the strict path but kept by the neutral one. Either way a salvaged list could show — and let the user call — a tool the connection's era says isn't valid. `toolItemSchemaForEra` closes both. Replicating rules that live in the SDK's internals is a real cost; `listSalvage-era.test.ts` is what makes it safe to pay, pinning each rule to the strict path's OBSERVED behavior so a divergence that closes or widens fails loudly instead of rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HDdo1rNRVQnVRRdsqSrvG3 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/listSalvage.test.ts | 14 ++ .../integration/mcp/listSalvage-era.test.ts | 214 ++++++++++++++++++ core/mcp/inspectorClient.ts | 11 +- core/mcp/listSalvage.ts | 44 +++- 4 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/listSalvage-era.test.ts diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts index a117403b..f547eee0 100644 --- a/clients/web/src/test/core/mcp/listSalvage.test.ts +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -132,6 +132,20 @@ describe("ModernResultEnvelopeSchema", () => { ).toBe(true); }); + it("requires ttlMs to be a non-negative integer, as the codec does", () => { + // A looser `z.number()` would accept `-1` / `0.5`, so a response with a bad + // envelope AND one bad entry would salvage and hide the envelope violation. + for (const ttlMs of [-1, 0.5]) { + expect( + ModernResultEnvelopeSchema.safeParse({ + resultType: "complete", + ttlMs, + cacheScope: "public", + }).success, + ).toBe(false); + } + }); + it("rejects a modern result missing the envelope the codec would enforce", () => { // The raw-wire salvage path goes around that codec, so a response missing // its cache hints AND carrying a bad entry must not salvage. diff --git a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts new file mode 100644 index 00000000..723b47da --- /dev/null +++ b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { ToolSchema } from "@modelcontextprotocol/core"; +import { toolItemSchemaForEra } from "@inspector/core/mcp/listSalvage.js"; + +/** + * The era item contract the salvage fallback validates tools against (#1909). + * + * `toolItemSchemaForEra` replicates two rules that live in the SDK's internal + * per-era wire schemas, which are not exported. That is a real cost, and these + * tests are what makes it safe to pay: each one asserts the STRICT path's + * observed behavior on a real connection, so if a future SDK closes or widens a + * divergence, the test fails and the replicated rule gets revisited instead of + * silently rotting. + * + * The failure being prevented: the fallback keeping an entry the strict path + * refused, which the user would then see — and could call — on a connection + * whose era says it isn't valid. + */ + +const ARRAY_OUTPUT_TOOL = { + name: "array_output", + inputSchema: { type: "object" }, + outputSchema: { type: "array" }, +}; + +const EXECUTION_TOOL = { + name: "with_execution", + inputSchema: { type: "object" }, + execution: { taskSupport: "optional" }, +}; + +function startToolsServer( + tools: unknown[], + modern: boolean, +): Promise<{ url: string; stop: () => Promise<void> }> { + const handler = async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString(); + if (raw.length === 0) { + res.writeHead(405).end(); + return; + } + const body = JSON.parse(raw) as { id?: unknown; method: string }; + if (body.id === undefined) { + res.writeHead(202).end(); + return; + } + const envelope = modern + ? { resultType: "complete", ttlMs: 0, cacheScope: "public" } + : {}; + const send = (result: object) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { ...envelope, ...result }, + }), + ); + }; + if (body.method === "initialize") { + send({ + protocolVersion: modern ? "2026-07-28" : "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "era-contract-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "server/discover") { + send({ + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + serverInfo: { name: "era-contract-server", version: "1.0.0" }, + }); + return; + } + if (body.method === "tools/list") { + send({ tools }); + return; + } + send({}); + }; + + const server: Server = createServer((req, res) => { + void handler(req, res); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + /* v8 ignore next -- listen() on a fresh server always yields an AddressInfo */ + const port = typeof address === "object" && address ? address.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + stop: () => + new Promise((done) => { + server.close(() => done()); + }), + }); + }); + }); +} + +describe("salvage era item contract (#1909)", () => { + let client: InspectorClient | null = null; + let stop: (() => Promise<void>) | null = null; + + afterEach(async () => { + try { + await client?.disconnect(); + } catch { + // Teardown only. + } + client = null; + await stop?.(); + stop = null; + }); + + async function connect(url: string, era: "legacy" | "modern") { + client = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation(era), + }, + ); + await client.connect(); + return client; + } + + it("legacy: the era rule is what rejects a non-object outputSchema (pins it)", async () => { + // The rule lives in the SDK's high-level aggregate (the era codec), not in + // the neutral schema the single-page call uses — which is precisely the + // divergence `toolItemSchemaForEra` exists to close. + // + // This tool is the page's ONLY problem, so the observable outcome is + // entirely the SDK's rule talking: the strict aggregate refuses the page, + // the fallback runs, and our contract agrees the tool is not era-valid. If + // a future SDK accepts it, the aggregate succeeds instead and this fails — + // which is the signal to revisit the replicated rule. + const server = await startToolsServer([ARRAY_OUTPUT_TOOL], false); + stop = server.stop; + const connected = await connect(server.url, "legacy"); + + const { tools } = await connected.listAllTools(); + expect(tools).toEqual([]); + expect( + connected.getMalformedListItems().map((entry) => entry.label), + ).toEqual(["array_output"]); + }); + + it("modern: the era rule accepts a tool but strips execution (pins it)", async () => { + // The mirror image: here the strict aggregate SUCCEEDS, so no salvage runs + // and what comes back is the SDK's own normalization. If it ever stops + // stripping, `execution` reappears here and this fails. + const server = await startToolsServer([EXECUTION_TOOL], true); + stop = server.stop; + const connected = await connect(server.url, "modern"); + + const { tools } = await connected.listAllTools(); + expect(tools).toHaveLength(1); + expect(tools[0]).not.toHaveProperty("execution"); + expect(connected.getMalformedListItems()).toEqual([]); + }); + + it("the neutral ToolSchema is looser than both, which is why the contract exists", () => { + expect(ToolSchema.safeParse(ARRAY_OUTPUT_TOOL).success).toBe(true); + const neutral = ToolSchema.safeParse(EXECUTION_TOOL); + expect(neutral.success).toBe(true); + expect(neutral.success && neutral.data).toHaveProperty("execution"); + }); + + it("the era contract matches the strict path on both divergences", () => { + // Legacy: rejected, exactly as the strict parse above rejected it. + expect( + toolItemSchemaForEra(false).safeParse(ARRAY_OUTPUT_TOOL).success, + ).toBe(false); + // Modern: accepted, with `execution` stripped, exactly as above. + const modern = toolItemSchemaForEra(true).safeParse(EXECUTION_TOOL); + expect(modern.success).toBe(true); + expect(modern.success && modern.data).not.toHaveProperty("execution"); + }); + + it("keeps only era-valid tools when salvaging beside a malformed one", async () => { + // The end-to-end shape of the defect: a malformed entry trips the + // fallback, and the array-output tool must NOT ride along on the strength + // of the neutral schema accepting it. + const server = await startToolsServer( + [ + { name: "ok_tool", inputSchema: { type: "object" } }, + ARRAY_OUTPUT_TOOL, + { name: "broken", inputSchema: "not-a-schema" }, + ], + false, + ); + stop = server.stop; + const connected = await connect(server.url, "legacy"); + + const { tools } = await connected.listAllTools(); + expect(tools.map((tool) => tool.name)).toEqual(["ok_tool"]); + expect( + connected.getMalformedListItems().map((entry) => entry.label), + ).toEqual(["array_output", "broken"]); + }); +}); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 90a6c37d..94a1abd2 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -161,8 +161,8 @@ import { ListPromptsResultSchema, // Per-primitive item schemas — the salvage fallback validates a list's // entries one at a time with these when the whole-result parse rejects - // (#1909, see `listAllSalvaging`). - ToolSchema, + // (#1909, see `listAllSalvaging`). Tools go through `toolItemSchemaForEra` + // instead, since the neutral schema is looser than the negotiated era's. ResourceSchema, ResourceTemplateSchema, PromptSchema, @@ -174,6 +174,7 @@ import { validateToolOutput } from "./toolOutputValidation.js"; import { ModernResultEnvelopeSchema, isSalvageableRejection, + toolItemSchemaForEra, nextCursorOf, lenientListPageSchema, rawItemsOf, @@ -3190,7 +3191,9 @@ export class InspectorClient extends InspectorClientEventTarget { method: "tools/list", itemsKey: "tools", resultSchema: ListToolsResultSchema, - itemSchema: ToolSchema, + // Era-aware: the neutral `ToolSchema` is more permissive than the + // negotiated era's wire schema — see `toolItemSchemaForEra`. + itemSchema: toolItemSchemaForEra(this.isModernEra()), metadata: options?.metadata, finalize: (salvaged) => this.excludeInvalidXMcpHeaderTools(salvaged), aggregate: async () => [ @@ -3656,7 +3659,7 @@ export class InspectorClient extends InspectorClientEventTarget { const { valid, malformed } = salvageListItems({ method: "tools/list", items, - schema: ToolSchema, + schema: toolItemSchemaForEra(this.isModernEra()), }); if (rejectedResponseId !== undefined && malformed.length > 0) { this.dispatchTypedEvent("responseRejected", { diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 37aeebbe..7c5ab1e4 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -25,6 +25,8 @@ import { z } from "zod/v4"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import { ToolSchema } from "@modelcontextprotocol/core"; +import type { Tool } from "@modelcontextprotocol/client"; /** * One entry the Inspector dropped from a list result because it failed the @@ -140,7 +142,10 @@ export function nextCursorOf(page: unknown): string | undefined { */ export const ModernResultEnvelopeSchema = z.looseObject({ resultType: z.literal("complete"), - ttlMs: z.number(), + // A non-negative INTEGER, matching the codec: `ttlMs: -1` or `0.5` is an + // envelope violation the strict path rejects, and accepting it here on the + // strength of an unrelated bad entry would hide it. + ttlMs: z.int().min(0), cacheScope: z.enum(["public", "private"]), }); @@ -261,3 +266,40 @@ export function summarizeMalformed(malformed: MalformedListItem[]): string { malformed.length === 1 ? "entry" : "entries" } — ${where}: ${first.reason}${rest}`; } + +/** + * The item contract for `tools/list` on a given era. + * + * The public `ToolSchema` is era-NEUTRAL, and the negotiated era's wire schema + * is stricter in ways the SDK does not export. Validating candidates against + * the neutral schema alone would let the fallback keep an entry the strict path + * refused — an entry the user would then see, and could call, on a connection + * whose era says it isn't valid. The two known divergences (verified against + * SDK 2.0.0 by `listSalvage-era.test.ts`, which fails if either changes): + * + * - **legacy (2025)** requires `outputSchema.type === "object"`; the neutral + * schema accepts any object, so a tool with `outputSchema: { type: "array" }` + * would survive salvage while the strict parse rejects the whole page. + * - **modern (2026)** deletes `execution`, and the strict path STRIPS it. Left + * in place, a salvaged tool would carry a field the era doesn't have, and the + * UI would render capability the connection can't honor. + * + * Replicating rules that live in the SDK's internals is a real cost, taken + * deliberately: the alternative is a fallback that is quietly more permissive + * than the path it stands in for. The tests pin each rule to observed SDK + * behavior, so a divergence that closes (or widens) fails loudly rather than + * rotting. + */ +export function toolItemSchemaForEra(isModern: boolean): z.ZodType<Tool> { + if (isModern) { + // `execution` is not part of the 2026 wire shape; drop it as the codec does. + return ToolSchema.transform((tool) => { + if (tool.execution === undefined) return tool; + const { execution: _execution, ...rest } = tool; + return rest; + }); + } + return ToolSchema.extend({ + outputSchema: z.looseObject({ type: z.literal("object") }).optional(), + }); +} From 32d6f80bc154afa91091b6cd8a6ad481042fcd1b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:54:58 -0400 Subject: [PATCH 140/941] fix: align enumNames when stripping null, and make the JSON fallback typeable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 5: one inline comment, two suppressed. All three valid, and two were defects from round 4's own fix. 1. Stripping the `null` sentinel from an `enum` left the parallel `enumNames` untouched. Both renderers discard labels outright when the two lengths disagree, so `{ enum: ["a", null], enumNames: ["Alpha", "None"] }` lost the `Alpha` label entirely — a worse outcome than the sentinel it was fixing. `enumNames` is now filtered by the same indices, and only when it was positionally parallel to begin with (a mismatched list is already ignored by both renderers, so re-indexing it would invent an alignment the server never declared). 2. `enum: [null]` became `enum: undefined`, which widened a schema permitting *only* `null` into a plain string field accepting arbitrary text — the form would then invite values the schema forbids. Trading a cosmetic problem for a correctness one, which was the wrong call. The collapse now declines entirely for that case and the field renders through the JSON editor. 3. That JSON editor was still carrying #1928's original mechanism: it stored unparseable text back as the *value*, which the next render re-`JSON.stringify`d, so typing `[` gave `"["` then `"\"[\""`. Routing nullable fields to real widgets removed the common way of landing there but never fixed the editor — and #2007's fix deliberately sends object unions to it, so a fallback nobody can type into is not a fallback. `SchemaJsonField` now holds the draft text locally (the split `SchemaNumberInput` already uses) and reports parsed JSON, or `undefined` while the text is mid-edit — an inspector must not report a value it cannot send. Keyed by `resetKey` like the number field, since it now holds state. Also replaces an `as unknown as string[]` in the round-4 test, which violated the repo's TypeScript rules and papered over `InspectorFormSchema.enum` being unable to model a valid `["a", null]`. The fixture is wire data, so it now goes through `toFormSchema` — the same wire→form narrowing boundary every production call site uses. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 78 ++++++++++++-- .../groups/SchemaForm/SchemaForm.tsx | 98 +++++++++++++++-- .../web/src/test/core/nullableUnion.test.ts | 48 ++++++++- core/json/nullableUnion.ts | 100 ++++++++++++++---- 4 files changed, 278 insertions(+), 46 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 0110bf00..bc6b75c9 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import { toFormSchema } from "../../../utils/jsonUtils"; import { fireEvent, renderWithMantine, @@ -737,7 +738,13 @@ describe("SchemaForm", () => { expect(lastCall.config).toEqual([1, 2]); }); - it("falls back to passing raw string to onChange when JSON is invalid in JsonInput", async () => { + // The JSON field used to store unparseable text back as the *value*, which + // the next render re-stringified — so each keystroke added a layer of + // escaping (`[` → `"["` → `"\"[\""`). That compounding escape is #1928's + // original symptom, and it lived here rather than in the dispatch. It matters + // more since #2007, whose fix deliberately routes object unions to this + // editor: a fallback nobody can type into is not a fallback. + it("reports no value, not raw text, while the JSON is mid-edit", async () => { const user = userEvent.setup(); const onChange = vi.fn(); const schema: InspectorFormSchema = { @@ -751,9 +758,59 @@ describe("SchemaForm", () => { ); const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; await user.type(jsonInput, "x"); - expect(onChange).toHaveBeenCalled(); - const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0]; - expect(typeof lastCall.config).toBe("string"); + expect(onChange).toHaveBeenLastCalledWith({ config: undefined }); + }); + + it("lets an array literal be typed one character at a time", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + // Drive the real controlled loop: each onChange feeds straight back in as + // `values`, which is what turned the old handler's raw-text write into a + // compounding re-escape. + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm schema={schema} values={values} onChange={setValues} /> + ); + } + renderWithMantine(<Harness />); + + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + // `[` is a userEvent keyboard descriptor, so it is escaped as `[[`. + await user.type(jsonInput, '[[1,"a"]'); + + // The box shows exactly what was typed — no injected quotes or backslashes. + expect(jsonInput.value).toBe('[1,"a"]'); + }); + + it("keeps an in-progress draft visible instead of rewriting it", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm schema={schema} values={values} onChange={setValues} /> + ); + } + renderWithMantine(<Harness />); + + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + await user.type(jsonInput, "[[1,"); + // Unparseable so far, and it must survive the re-render untouched. + expect(jsonInput.value).toBe("[1,"); + + await user.type(jsonInput, "2]"); + expect(jsonInput.value).toBe("[1,2]"); }); it("uses default values when value is undefined", () => { @@ -891,18 +948,23 @@ describe("SchemaForm nullable unions", () => { it("renders a Select for a type: [string, null] enum, without a null option", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - const schema: InspectorFormSchema = { + // Built through `toFormSchema`, the same narrowing boundary every + // production call site uses, rather than cast into `InspectorFormSchema`. + // A `null` member is valid JSON Schema but outside that type's `string[]` + // `enum`, and this fixture *is* wire data — so the honest way to introduce + // it is the wire→form narrow, not a double cast that erases the mismatch. + const schema = toFormSchema({ type: "object", properties: { direction: { title: "Direction", type: ["string", "null"], - enum: ["envio", "recebimento", null] as unknown as string[], + enum: ["envio", "recebimento", null], }, }, - }; + }); renderWithMantine( - <SchemaForm schema={schema} values={{}} onChange={onChange} />, + <SchemaForm schema={schema!} values={{}} onChange={onChange} />, ); await user.click(screen.getByRole("textbox", { name: "Direction" })); const options = await screen.findAllByRole("option", { hidden: true }); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 9cba4bd9..fc64c218 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -146,6 +146,88 @@ function toNumericValue(raw: string | number): number | undefined { return Number.isSafeInteger(Math.trunc(parsed)) ? parsed : undefined; } +/** Parse editor text, reporting `undefined` for anything that is not JSON yet. */ +function parseJsonDraft(text: string): unknown { + if (text.trim() === "") { + return undefined; + } + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Structural equality for the draft/value re-sync, via canonical JSON. */ +function isSameJson(a: unknown, b: unknown): boolean { + if (a === undefined || b === undefined) { + return a === b; + } + return serializeJson(a) === serializeJson(b); +} + +interface SchemaJsonFieldProps { + label: string; + description?: string; + withAsterisk: boolean; + disabled: boolean; + value: unknown; + onChange: (value: unknown) => void; +} + +/** + * A `JsonInput` that keeps the text the user is typing, not just the value it + * currently parses to. + * + * This field used to drive the box straight off the parent's value and, when + * the text failed `JSON.parse`, store the **raw text** back as the value — which + * the next render re-`JSON.stringify`d, adding a layer of escaping per + * keystroke. Typing `[` gave `"["`, then `"\"[\""`, and the field was unusable + * within a few characters. That compounding escape is the original #1928 + * symptom, and it lived in this fallback rather than in the dispatch. + * + * Routing nullable fields to real widgets removed the common way of *landing* + * here, but it did not fix the editor — and #2007's fix deliberately sends a + * union of object shapes to it, so the editor itself has to be typeable. Hence + * the same split `SchemaNumberInput` uses: the raw text is the source of truth + * for what is *displayed*, while the parent only ever sees parsed JSON. + * + * While the text is mid-edit and does not parse, the parent is told + * `undefined` rather than handed the text. An inspector must not report a value + * it cannot send, and `undefined` is how "no value supplied" is spelled + * everywhere else in this form. + */ +function SchemaJsonField({ + value, + onChange, + ...inputProps +}: SchemaJsonFieldProps) { + const [draft, setDraft] = useState(() => + value === undefined ? "" : serializeJson(value), + ); + + // Re-sync only when the parent's value genuinely diverges from what the draft + // parses to, which leaves an external reset (a cleared form, a loaded + // example) working while an in-progress `[` — whose parse is `undefined`, + // matching the `undefined` we just emitted — is left alone. + useValueChange(value, (next) => { + if (!isSameJson(parseJsonDraft(draft), next)) { + setDraft(next === undefined ? "" : serializeJson(next)); + } + }); + + return ( + <SchemaJsonInput + {...inputProps} + value={draft} + onChange={(text) => { + setDraft(text); + onChange(parseJsonDraft(text)); + }} + /> + ); +} + interface SchemaNumberInputProps { label: string; description?: string; @@ -440,20 +522,16 @@ export function SchemaForm({ // fallback: JsonInput for complex schemas return ( - <SchemaJsonInput - key={fieldName} + <SchemaJsonField + // Holds local draft state, so — like the number field — it has to be + // remounted when `resetKey` says the form moved to another entity. + key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} label={label} description={description} withAsterisk={isRequired} disabled={disabled} - value={rawValue !== undefined ? serializeJson(rawValue) : ""} - onChange={(val) => { - try { - handleFieldChange(fieldName, JSON.parse(val)); - } catch { - handleFieldChange(fieldName, val); - } - }} + value={rawValue} + onChange={(val) => handleFieldChange(fieldName, val)} /> ); } diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 2bf7a1c9..73dba382 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -277,14 +277,52 @@ describe("normalizeNullableUnion", () => { }); }); - it("drops an enum that held nothing but null", () => { - // No value a dropdown could offer, so the field is better served by its - // plain widget than by an empty select. + // Declining is the point: emitting `enum: undefined` would turn a schema that + // permits *only* `null` into a plain string field accepting arbitrary text, + // inviting values the schema forbids. Left uncollapsed it renders through the + // JSON editor, which represents it honestly. + it("declines to collapse an enum that held nothing but null", () => { + const schema = { type: ["string", "null"], enum: [null] }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines the same way on the anyOf encoding", () => { + const schema = { + anyOf: [{ type: "string", enum: [null] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // The parallel `enumNames` is filtered by the same indices. Both renderers + // discard labels outright on a length mismatch, so stripping one without the + // other would silently lose every label, not just the dropped one's. + it("keeps enumNames aligned when stripping the null member", () => { expect( - normalizeNullableUnion({ type: ["string", "null"], enum: [null] }), + normalizeNullableUnion({ + type: ["string", "null"], + enum: ["a", null, "b"], + enumNames: ["Alpha", "None", "Beta"], + }), ).toEqual({ type: "string", - enum: undefined, + enum: ["a", "b"], + enumNames: ["Alpha", "Beta"], + anyOf: undefined, + nullable: true, + }); + }); + + it("leaves a mismatched enumNames alone rather than inventing an alignment", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: ["a", null], + enumNames: ["Alpha"], + }), + ).toEqual({ + type: "string", + enum: ["a"], + enumNames: ["Alpha"], anyOf: undefined, nullable: true, }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index edcee0c4..1b5c9f9f 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -102,27 +102,64 @@ export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit< }; /** - * Drop a `null` sentinel from an `enum`, since the collapse has already moved - * that fact onto `nullable`. + * What {@link stripNullEnumMembers} concluded about an `enum`. + * + * `"only-null"` is a distinct outcome rather than "an empty list" because it + * has to **stop the collapse**, not just empty a field. See below. + */ +type EnumStrip = + | { kind: "unchanged" } + | { kind: "filtered"; members: unknown[]; names?: unknown } + | { kind: "only-null" }; + +/** + * Drop `null` members from an `enum`, keeping the parallel `enumNames` aligned. * * The `type: [T, "null"]` encoding keeps its keywords at the top level, so a * nullable enum written that way carries the null *inside* the list: - * `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. Leaving - * it there breaks both renderers in different ways — the web dispatcher would - * hand `null` to Mantine as option data, and the TUI's all-strings check would - * reject the whole enum and fall back to a plain text field. Neither is the - * dropdown the schema is asking for. + * `{ type: ["string", "null"], enum: ["envio", "recebimento", null] }`. The + * collapse has already moved that fact onto `nullable`, so leaving the sentinel + * in the list breaks both renderers in different ways — the web dispatcher + * would hand `null` to Mantine as option data, and the TUI's all-strings check + * would reject the whole enum and fall back to a plain text field. * - * Returns `undefined` when nothing but `null` was in the list: an enum of only - * `null` permits no value a dropdown could offer, so the field is better served - * by its plain widget than by an empty select. + * **`enumNames` is filtered by the same indices, not left alone.** It is a + * positional parallel array, and both renderers discard labels outright when + * the two lengths disagree — so stripping one without the other silently loses + * every label rather than just the dropped one's. + * + * **An enum of nothing but `null` reports `"only-null"` so the caller declines + * to collapse at all.** Emitting `enum: undefined` instead would turn a schema + * permitting *only* `null` into a plain string field that accepts arbitrary + * text — trading a cosmetic problem for a correctness one, since the form would + * then invite values the schema forbids. Left uncollapsed it renders through + * the JSON editor, which represents it honestly. */ -function stripNullFromEnum(value: unknown): unknown[] | undefined { - if (!Array.isArray(value)) { - return undefined; +function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip { + if (!Array.isArray(members)) { + return { kind: "unchanged" }; } - const members = value.filter((member) => member !== null); - return members.length > 0 ? members : undefined; + const kept = members + .map((member, index) => ({ member, index })) + .filter((entry) => entry.member !== null); + if (kept.length === members.length) { + return { kind: "unchanged" }; + } + if (kept.length === 0) { + return { kind: "only-null" }; + } + // Only realign names that were positionally parallel to begin with; a + // mismatched list is already ignored by both renderers, so re-indexing it + // would invent an alignment the server never declared. + const aligned = + Array.isArray(names) && names.length === members.length + ? kept.map((entry) => names[entry.index]) + : names; + return { + kind: "filtered", + members: kept.map((entry) => entry.member), + names: aligned, + }; } /** @@ -133,23 +170,32 @@ function stripNullFromEnum(value: unknown): unknown[] | undefined { * deferred while `T` is open — so TS cannot verify the two line up even though * every field is set right here. Isolated in this one function so the exported * signature carries the contract and nothing else has to assert it. + * + * Returns `null` when the merged schema turns out to permit only `null`, which + * is not collapsible; see {@link stripNullEnumMembers}. */ function collapsed<T extends NullableUnionSchema>( schema: T, branch: Record<string, unknown> | undefined, type: RenderableType, -): NormalizedNullableUnion<T> { +): NormalizedNullableUnion<T> | null { const merged = { ...schema, ...branch }; + // Read after the merge so the branch's list wins when it has one. + const strip = stripNullEnumMembers( + merged.enum, + (merged as { enumNames?: unknown }).enumNames, + ); + if (strip.kind === "only-null") { + return null; + } return { ...merged, type, anyOf: undefined, nullable: true, - // `enum` is read after the spread so the branch's list wins when it has - // one, and the null sentinel is stripped either way. - ...(merged.enum === undefined - ? {} - : { enum: stripNullFromEnum(merged.enum) }), + ...(strip.kind === "filtered" + ? { enum: strip.members, enumNames: strip.names } + : {}), } as NormalizedNullableUnion<T>; } @@ -233,7 +279,12 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( const type = branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined); if (isRenderableType(type)) { - return collapsed(schema, branch, type); + // `null` when the merged enum permits only `null`, which is not + // collapsible — fall through and return the schema by identity. + const result = collapsed(schema, branch, type); + if (result !== null) { + return result; + } } } } @@ -245,7 +296,10 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { - return collapsed(schema, undefined, type); + const result = collapsed(schema, undefined, type); + if (result !== null) { + return result; + } } } From 86ba42633cd4cb44d3f7edaa1972de2650da1099 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:11:49 -0400 Subject: [PATCH 141/941] fix: honor sibling constraints on nullability, guard numeric item enums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 6: no visible comments, three suppressed. All valid. 1. JSON Schema keywords at one level are conjunctive, so a syntactic null does not by itself mean the schema accepts null. `{ type: ["string", "null"], enum: ["envio"] }` names "null" and still rejects it — yet the collapse marked it `nullable: true`, giving the field a clear button that emits a value the schema forbids, and `admitsNull` let required-field gating accept it. Both now consult `nullExcludedBySiblings`. The distinction that makes this safe: a *branch's* `enum` is not a sibling. In `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` — the #1928 shape — the enum constrains that branch alone and the union still permits null, so `nullable` is read off the ORIGINAL schema rather than the hoisted merge. Reading the merge would have called the headline case non-nullable. Flattening is unaffected either way; only the nullability claim is withheld. 2. The collapse newly routed a nullable array of numeric item enums into the string-only `MultiSelect` path, which would submit `["1"]` for `[1]`. Gated on `isStringEnum`, matching `toConstOptions`; `isStringEnum` is now a type predicate so the narrowing is real rather than asserted. 3. Invalid JSON and an empty field both report `undefined`, so an optional complex argument could be dropped from the submission while invalid text sat visible in the box. The field now says so via `error`. Note a *required* field was already gated — its value is `undefined`, which `hasMissingRequiredFields` treats as missing — so this is specifically the optional case. Disabling Execute/Open/Submit on an invalid optional draft needs a validity channel out of `SchemaForm` into all four call sites, which is wider than this fix; filed separately. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 85 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 41 +++++++-- .../web/src/test/core/nullableUnion.test.ts | 81 +++++++++++++++--- core/json/nullableUnion.ts | 37 +++++++- 4 files changed, 220 insertions(+), 24 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index bc6b75c9..c2392ec8 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -744,6 +744,45 @@ describe("SchemaForm", () => { // original symptom, and it lived here rather than in the dispatch. It matters // more since #2007, whose fix deliberately routes object unions to this // editor: a fallback nobody can type into is not a fallback. + it("shows an error while the JSON draft does not parse", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + function Harness() { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm schema={schema} values={values} onChange={setValues} /> + ); + } + renderWithMantine(<Harness />); + + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + await user.type(jsonInput, "[[1,"); + // Invalid text yields no value, so without this the field would submit as + // absent while the user is still looking at what they typed. + expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument(); + + await user.type(jsonInput, "2]"); + expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument(); + }); + + it("shows no error for an empty optional field", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + renderWithMantine( + <SchemaForm schema={schema} values={{}} onChange={vi.fn()} />, + ); + expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument(); + }); + it("reports no value, not raw text, while the JSON is mid-edit", async () => { const user = userEvent.setup(); const onChange = vi.fn(); @@ -1183,6 +1222,52 @@ describe("SchemaForm nullable unions", () => { expect(screen.getByRole("textbox", { name: "Items" })).toBeInTheDocument(); }); + // Reachable only since the collapse landed: the nullable wrapper had no + // top-level `type` before, so it fell to the JSON editor rather than into a + // string-valued MultiSelect that would submit ["1"] for [1]. + it("falls back to the JSON input for a nullable array of numeric item enums", () => { + // Wire data again — a numeric `enum` is valid JSON Schema but outside + // `InspectorFormSchema`'s `string[]`, so it comes in through the narrow. + const schema = toFormSchema({ + type: "object", + properties: { + levels: { + title: "Levels", + anyOf: [{ type: "array", items: { enum: [1, 2] } }, { type: "null" }], + }, + }, + }); + renderWithMantine( + <SchemaForm schema={schema!} values={{}} onChange={vi.fn()} />, + ); + expect(screen.getByLabelText(/Levels/).tagName).toBe("TEXTAREA"); + }); + + // The sibling enum rules null out even though the type list names it, so the + // field must not get a clear button that emits a value the schema rejects. + it("offers no clear button when a sibling enum excludes null", () => { + const schema = toFormSchema({ + type: "object", + properties: { + direction: { + title: "Direction", + type: ["string", "null"], + enum: ["envio", "recebimento"], + }, + }, + }); + renderWithMantine( + <SchemaForm + schema={schema!} + values={{ direction: "envio" }} + onChange={vi.fn()} + />, + ); + expect( + screen.queryByRole("button", { hidden: true }), + ).not.toBeInTheDocument(); + }); + it("still falls back to the JSON input for a union of two real types", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index fc64c218..89a88c23 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -15,7 +15,10 @@ import type { InspectorFormSchema, JsonSchemaConst, } from "../../../utils/jsonUtils"; -import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js"; +import { + isStringEnum, + normalizeNullableUnion, +} from "@inspector/core/json/nullableUnion.js"; const FieldLabel = Text.withProps({ fw: 500, @@ -196,6 +199,15 @@ interface SchemaJsonFieldProps { * `undefined` rather than handed the text. An inspector must not report a value * it cannot send, and `undefined` is how "no value supplied" is spelled * everywhere else in this form. + * + * That leaves invalid text and an empty field indistinguishable *to the + * parent*, so the field says so itself via `error`. A **required** field is + * already gated — its value is `undefined`, which `hasMissingRequiredFields` + * treats as missing — but an **optional** one would otherwise be dropped from + * the submission with no indication. Disabling Execute/Open/Submit on an + * invalid optional draft needs a validity channel out of this component and + * into all four callers, which is a wider change than this fix; tracked + * separately. */ function SchemaJsonField({ value, @@ -216,10 +228,23 @@ function SchemaJsonField({ } }); + // Text that is present but does not parse yields no value, so the field would + // otherwise submit as absent while the user is still looking at what they + // typed. Saying so on the field is what keeps that from being silent; see the + // note on `SchemaJsonField` for the submit-gating half, which is not local to + // this component. + const hasInvalidDraft = + draft.trim() !== "" && parseJsonDraft(draft) === undefined; + return ( <SchemaJsonInput {...inputProps} value={draft} + error={ + hasInvalidDraft + ? "Not valid JSON — this field will be omitted" + : undefined + } onChange={(text) => { setDraft(text); onChange(parseJsonDraft(text)); @@ -458,12 +483,14 @@ export function SchemaForm({ ); } - // array of enum values (multi-select) - if (fieldSchema.type === "array" && fieldSchema.items?.enum) { - const data = toEnumData( - fieldSchema.items.enum, - fieldSchema.items.enumNames, - ); + // array of enum values (multi-select). Gated on the members being strings + // for the same reason `toConstOptions` is: `MultiSelect` is string-valued, + // so `items: { enum: [1, 2] }` would submit `["1"]` where the schema says + // `[1]`. Reachable from a nullable array since the collapse landed, which + // is what makes the guard load-bearing rather than theoretical. + const itemsEnum = fieldSchema.items?.enum; + if (fieldSchema.type === "array" && isStringEnum(itemsEnum)) { + const data = toEnumData(itemsEnum, fieldSchema.items?.enumNames); return ( <MultiSelect key={fieldName} diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 73dba382..d5d55bba 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -312,6 +312,35 @@ describe("normalizeNullableUnion", () => { }); }); + // Flattening still happens — only the nullability claim is withheld, because + // the sibling enum rules null out even though the type list names it. + it("flattens but does not mark nullable when a sibling enum excludes null", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: ["envio", "recebimento"], + }), + ).toEqual({ + type: "string", + enum: ["envio", "recebimento"], + anyOf: undefined, + nullable: false, + }); + }); + + it("still marks nullable when the enum lives on an anyOf branch", () => { + expect( + normalizeNullableUnion({ + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["envio"], + anyOf: undefined, + nullable: true, + }); + }); + it("leaves a mismatched enumNames alone rather than inventing an alignment", () => { expect( normalizeNullableUnion({ @@ -328,20 +357,6 @@ describe("normalizeNullableUnion", () => { }); }); - it("leaves a null-free enum untouched", () => { - expect( - normalizeNullableUnion({ - type: ["string", "null"], - enum: ["a", "b"], - }), - ).toEqual({ - type: "string", - enum: ["a", "b"], - anyOf: undefined, - nullable: true, - }); - }); - it("normalizes type array array|null", () => { expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ type: "array", @@ -424,4 +439,42 @@ describe("admitsNull", () => { it("ignores non-object anyOf members instead of throwing", () => { expect(admitsNull({ anyOf: ["null", ["null"], 7] })).toBe(false); }); + + // JSON Schema keywords at one level are conjunctive, so a syntactic null does + // not by itself mean the schema accepts null. `{ type: ["string","null"], + // enum: ["envio"] }` names "null" and still rejects it. + it("is false when a sibling enum excludes null", () => { + expect(admitsNull({ type: ["string", "null"], enum: ["envio"] })).toBe( + false, + ); + expect( + admitsNull({ + anyOf: [{ type: "string" }, { type: "null" }], + enum: ["a"], + }), + ).toBe(false); + }); + + it("is true when a sibling enum offers null", () => { + expect( + admitsNull({ type: ["string", "null"], enum: ["envio", null] }), + ).toBe(true); + }); + + it("is false when a sibling const pins a non-null value", () => { + expect(admitsNull({ type: ["string", "null"], const: "envio" })).toBe( + false, + ); + expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true); + }); + + // A branch's enum is scoped to that branch, not a sibling of the union — this + // is the #1928 shape, and reading it as a sibling would call it non-nullable. + it("is unaffected by an enum that lives inside an anyOf branch", () => { + expect( + admitsNull({ + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }), + ).toBe(true); + }); }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 1b5c9f9f..6141bcdd 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -43,9 +43,10 @@ export interface NullableUnionSchema { enum?: unknown[]; anyOf?: readonly unknown[]; // Read only by `admitsNull`, which considers encodings the collapse itself - // declines to flatten. + // declines to flatten, and the sibling constraints that can rule null out. oneOf?: readonly unknown[]; nullable?: boolean; + const?: unknown; } /** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */ @@ -71,7 +72,7 @@ function toBranch(value: unknown): Record<string, unknown> | null { * an enum to a select at all. The TUI's does, and its options are stringified, * so an unguarded numeric enum there submits `"1"` where the server wants `1`. */ -export function isStringEnum(value: unknown): boolean { +export function isStringEnum(value: unknown): value is string[] { return ( Array.isArray(value) && value.length > 0 && @@ -101,6 +102,30 @@ export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit< nullable?: boolean; }; +/** + * Whether a schema's **own** `enum`/`const` rules `null` out, regardless of what + * its `type` or union branches say. + * + * JSON Schema keywords at one level are **conjunctive** — a value must satisfy + * all of them — so a syntactic null does not by itself mean the schema accepts + * null. `{ type: ["string", "null"], enum: ["envio"] }` names `"null"` in its + * type list and still rejects `null`, because the `enum` does not offer it. + * Marking that field nullable would give it a clear button that emits a value + * the schema forbids, and would make required-field gating accept it. + * + * Evaluated against the schema's own level only. A branch's `enum` is *not* a + * sibling — in `anyOf: [{ type: "string", enum: [...] }, { type: "null" }]` the + * enum constrains that branch alone, and the union still permits null. Reading + * it as a sibling would break exactly the shape #1928 is about, so callers must + * pass the original schema here, never the hoisted merge. + */ +function nullExcludedBySiblings(schema: NullableUnionSchema): boolean { + if (Array.isArray(schema.enum) && !schema.enum.includes(null)) { + return true; + } + return schema.const !== undefined && schema.const !== null; +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -192,7 +217,10 @@ function collapsed<T extends NullableUnionSchema>( ...merged, type, anyOf: undefined, - nullable: true, + // Read off the ORIGINAL schema, not the merge: a hoisted branch `enum` is + // not a sibling constraint, and treating it as one would mark the very + // shape #1928 is about as non-nullable. See `nullExcludedBySiblings`. + nullable: admitsNull(schema), ...(strip.kind === "filtered" ? { enum: strip.members, enumNames: strip.names } : {}), @@ -216,6 +244,9 @@ function collapsed<T extends NullableUnionSchema>( * branch anywhere in an `anyOf` or `oneOf` of any size. */ export function admitsNull(schema: NullableUnionSchema): boolean { + if (nullExcludedBySiblings(schema)) { + return false; + } if (schema.nullable === true) { return true; } From 987279b5d3a5972bd3e60e827a7cbef8b80b8345 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:26:14 -0400 Subject: [PATCH 142/941] fix: stop the hoist dropping wrapper constraints, and let an explicit type decide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 7: both findings are the same principle applied one level further — JSON Schema siblings are conjunctive, and this code was treating them as alternatives. 1. The hoist is a spread, so a branch keyword *replaced* the wrapper's. That is not a conjunction: a wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means `"a"`, but the spread produced `["a", "b"]` and the dropdown would have offered — and submitted — a `"b"` the schema rejects. `type`, bounds, and the object keywords fail the same way. The collapse now declines when wrapper and branch carry different values for the same validation keyword, and the field renders through the JSON editor with its schema intact. Intersecting them properly is a much larger surface, and a subtly wrong intersection would be worse than none. Identical values are not a conflict, so the ordinary redundant-`type` shape still collapses; metadata (`title`, `description`, `default`, `enumNames`) never conflicts, since nothing validates against it. 2. `admitsNull` let a `{ type: "null" }` branch outvote an explicit non-null top-level `type`. `{ type: "string", anyOf: [..., { type: "null" }] }` rejects null — the value must satisfy the type *and* the union — so an explicit `type` now decides instead of falling through to the branch scan. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 88 +++++++++++++++++++ core/json/nullableUnion.ts | 84 ++++++++++++++++-- 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index d5d55bba..a2127f2b 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -390,6 +390,70 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); + // The hoist is a spread, but JSON Schema applies sibling keywords + // conjunctively — so a branch keyword replacing the wrapper's would *widen* + // the field. `enum: ["a"]` around a branch `enum: ["a","b"]` means "a", yet + // the spread would offer "b" in the dropdown and submit it. + it("declines when the branch would drop a conflicting wrapper enum", () => { + const schema = { + enum: ["a"], + anyOf: [{ type: "string", enum: ["a", "b"] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when wrapper and branch disagree on type", () => { + const schema = { + type: "string", + anyOf: [{ type: "integer" }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when wrapper and branch disagree on bounds", () => { + const schema = { + minimum: 5, + anyOf: [{ type: "integer", minimum: 1 }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + // Identical values are no conflict, so the ordinary redundant-type shape + // still collapses rather than being punished for being explicit. + it("collapses when wrapper and branch agree on a shared keyword", () => { + expect( + normalizeNullableUnion({ + type: "string", + anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }], + }), + ).toEqual({ + type: "string", + enum: ["a"], + anyOf: undefined, + // The explicit non-null `type` rules null out, conjunctively. + nullable: false, + }); + }); + + it("treats wrapper metadata as safe to keep alongside branch constraints", () => { + expect( + normalizeNullableUnion({ + title: "Direction", + description: "Which way", + default: "envio", + anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }], + }), + ).toEqual({ + title: "Direction", + description: "Which way", + default: "envio", + type: "string", + enum: ["envio"], + anyOf: undefined, + nullable: true, + }); + }); + it("ignores a schema with no union keywords at all", () => { const schema = { type: "object" as const, properties: {} }; expect(normalizeNullableUnion(schema)).toBe(schema); @@ -470,6 +534,30 @@ describe("admitsNull", () => { // A branch's enum is scoped to that branch, not a sibling of the union — this // is the #1928 shape, and reading it as a sibling would call it non-nullable. + // An explicit `type` is conjunctive with the union, so it decides rather than + // merely offering another way to say yes. A `{ type: "null" }` branch cannot + // override a top-level `type: "string"`. + it("lets an explicit non-null type override a null branch", () => { + expect( + admitsNull({ + type: "string", + anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }], + }), + ).toBe(false); + }); + + it("still honors an explicit type that does admit null", () => { + expect( + admitsNull({ + type: ["string", "null"], + anyOf: [{ type: "string" }, { type: "null" }], + }), + ).toBe(true); + expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe( + true, + ); + }); + it("is unaffected by an enum that lives inside an anyOf branch", () => { expect( admitsNull({ diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 6141bcdd..c24fec92 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -126,6 +126,72 @@ function nullExcludedBySiblings(schema: NullableUnionSchema): boolean { return schema.const !== undefined && schema.const !== null; } +/** + * Keywords that *constrain what values are valid*, as opposed to describing the + * field (`title`, `description`, `default`, `enumNames`). + * + * Only these can conflict in a way that matters: a wrapper and its branch each + * carrying one means the value must satisfy **both**, which the hoist cannot + * express. Metadata is safe to merge because nothing validates against it. + */ +const VALIDATION_KEYWORDS = [ + "type", + "enum", + "const", + "items", + "properties", + "required", + "additionalProperties", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems", +] as const; + +/** + * Whether hoisting this branch would *drop* one of the wrapper's constraints. + * + * The hoist is a spread, so a branch key replaces the wrapper's — but JSON + * Schema applies sibling keywords **conjunctively**, and a replacement is not a + * conjunction. A wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means + * `"a"` (both must hold), yet the spread yields `["a", "b"]` and the rendered + * dropdown would offer — and submit — a `"b"` the schema rejects. `type`, + * bounds, and the object keywords fail the same way. + * + * Rather than implement intersection for every keyword (a much larger surface, + * and one where a subtly wrong intersection is worse than none), the collapse + * **declines** when wrapper and branch disagree, and the field renders through + * the JSON editor with its full schema intact. Identical values are not a + * conflict, so the common `{ type: "string", anyOf: [{ type: "string", … }] }` + * still collapses. + * + * Comparison is by canonical JSON, so two structurally equal objects written + * with different key order read as a conflict. That is conservative in the safe + * direction: the field falls back rather than mis-renders. + */ +function branchDropsWrapperConstraint( + schema: NullableUnionSchema, + branch: Record<string, unknown>, +): boolean { + const wrapper = schema as Record<string, unknown>; + return VALIDATION_KEYWORDS.some((keyword) => { + const wrapperValue = wrapper[keyword]; + const branchValue = branch[keyword]; + if (wrapperValue === undefined || branchValue === undefined) { + return false; + } + return JSON.stringify(wrapperValue) !== JSON.stringify(branchValue); + }); +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -250,11 +316,15 @@ export function admitsNull(schema: NullableUnionSchema): boolean { if (schema.nullable === true) { return true; } - if (schema.type === "null") { - return true; - } - if (Array.isArray(schema.type) && schema.type.includes("null")) { - return true; + // An explicit `type` is a sibling constraint too, so it *decides* — it does + // not merely add a way to say yes. `{ type: "string", anyOf: [..., { type: + // "null" }] }` rejects null, because a value must satisfy the `type` as well + // as the union. Falling through to the branch scan here would let the union + // override a constraint that outranks it. + if (schema.type !== undefined) { + return Array.isArray(schema.type) + ? schema.type.includes("null") + : schema.type === "null"; } return [schema.anyOf, schema.oneOf].some((branches) => branches?.some((entry) => { @@ -303,7 +373,9 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( (entry) => entry !== null && entry.type !== "null", ); - if (nullBranch && branch) { + // Hoisting would silently drop a wrapper constraint the branch also + // carries, so decline instead; see `branchDropsWrapperConstraint`. + if (nullBranch && branch && !branchDropsWrapperConstraint(schema, branch)) { // A branch may carry an `enum` and no `type`; JSON Schema allows that, and // an all-string enum is unambiguously a string field. See isStringEnum for // why a non-string enum deliberately does not get the same treatment. From b25ece8ded1da31003f926b12ce216119e695863 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 18:58:46 -0400 Subject: [PATCH 143/941] feat: per-server custom OAuth authorization-request parameters (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real deployments gate IdP selection, prompt behavior, or audience on a query parameter the OAuth/OIDC core specs never standardized (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`), and none of them could be driven from the Inspector. The Inspector never builds the authorize URL — the SDK's `auth()` does, and it exposes no hook for extra parameters — so the merge happens at the one seam that sees the finished URL: `BaseOAuthClientProvider.redirectToAuthorization`. It lives in `core/`, so web, CLI, and TUI all inherit it. - `core/auth/authorizationParams.ts`: the reserved-key list (`client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state`), the key validator the form renders inline, and the merge itself. Reserved keys are dropped with a warning rather than silently overriding PKCE / the state binding / RFC 8707; blank-key rows are skipped; the caller's URL is never mutated. - Applied to the authorization request only — nothing wires it into the token request. - Persisted as `oauth.authorizationParams` (a string record) on the per-server config beside `scopes`, edited as key/value rows in Server Settings -> Authorization ("Additional authorization parameters"), with an inline error per offending row plus a section-level warning. - The enterprise-managed (EMA) leg redirects to a *different* authorization server (the enterprise IdP), so it goes through the explicit `redirectToExternalAuthorization` entry point, which skips the merge — a `kc_idp_hint` or `audience` meant for the MCP server's AS must never be appended to the IdP's authorize URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 9 +- clients/web/src/App.tsx | 8 + .../ServerSettingsForm.test.tsx | 157 ++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 148 +++++++++++++++++ .../ServerSettingsModal.test.tsx | 25 +++ .../ServerSettingsModal.tsx | 4 + .../core/auth/authorizationParams.test.ts | 111 +++++++++++++ .../core/auth/ema/transportProvider.test.ts | 47 +++++- .../web/src/test/core/auth/providers.test.ts | 62 +++++++ .../web/src/test/core/client/runner.test.ts | 38 +++++ .../web/src/test/core/mcp/serverList.test.ts | 78 +++++++++ .../mcp/remote/server-extra-coverage.test.ts | 52 ++++++ .../mcp/remote/servers-route.test.ts | 48 ++++++ core/auth/authorizationParams.ts | 105 ++++++++++++ core/auth/ema/transportProvider.ts | 6 +- core/auth/index.ts | 9 + core/auth/providers.ts | 40 +++++ core/client/runner.ts | 9 + core/mcp/inspectorClient.ts | 2 + core/mcp/oauthManager.ts | 6 + core/mcp/remote/node/server.ts | 27 ++- core/mcp/serverList.ts | 45 +++++ core/mcp/types.ts | 30 ++++ docs/mcp-server-configuration.md | 4 +- 24 files changed, 1064 insertions(+), 6 deletions(-) create mode 100644 clients/web/src/test/core/auth/authorizationParams.test.ts create mode 100644 core/auth/authorizationParams.ts diff --git a/AGENTS.md b/AGENTS.md index 0baea459..cfd08caa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,14 @@ v2/main/ │ │ # issuerBinding.ts SEP-2352 callback-leg failure │ │ # classification — separates a recoverable │ │ # "lost authorization state" from a genuine -│ │ # cross-AS issuer mismatch — #1808) +│ │ # cross-AS issuer mismatch — #1808; +│ │ # authorizationParams.ts per-server custom +│ │ # authorization-request parameters — the +│ │ # reserved-key list plus the merge applied in +│ │ # providers.redirectToAuthorization, the only +│ │ # seam that sees the SDK-built authorize URL; +│ │ # authorization request only, never the token +│ │ # request — #2018) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) │ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, │ │ │ # runner-interactive-oauth loopback callback flow) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 509fc079..97aefe73 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -63,6 +63,7 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js"; import { cleanRoots, + oauthAuthorizationParamsFromSettings, serializeMcpConfig, } from "@inspector/core/mcp/serverList.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; @@ -2301,11 +2302,15 @@ function App() { .map((m) => [m.key, m.value]), ) : undefined; + const serverAuthorizationParams = savedSettings + ? oauthAuthorizationParamsFromSettings(savedSettings) + : undefined; const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || + serverAuthorizationParams || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -2317,6 +2322,9 @@ function App() { ...(savedSettings.oauthScopes && { scope: savedSettings.oauthScopes, }), + ...(serverAuthorizationParams && { + authorizationParams: serverAuthorizationParams, + }), ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 43b400bf..b66d76ac 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -700,10 +700,165 @@ describe("ServerSettingsForm", () => { clientId: "a", clientSecret: "", scopes: "", + authorizationParams: [], enterpriseManaged: false, }); }); + // #2018 — custom authorization-request parameters. + it("shows the empty hint for authorization parameters when none are set", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={emptySettings} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByText("Additional authorization parameters"), + ).toBeInTheDocument(); + expect( + screen.getByText("No additional parameters configured"), + ).toBeInTheDocument(); + }); + + it("adds an authorization-parameter row through onOAuthChange", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + onOAuthChange={onOAuthChange} + settings={emptySettings} + expandedSections={["oauth"]} + />, + ); + await user.click(screen.getByRole("button", { name: "+ Add Parameter" })); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationParams: [{ key: "", value: "" }], + }), + ); + }); + + it("edits and removes an authorization-parameter row", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + onOAuthChange={onOAuthChange} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }], + }} + expandedSections={["oauth"]} + />, + ); + const valueInput = screen.getByDisplayValue("corp"); + await user.type(valueInput, "x"); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + authorizationParams: [{ key: "kc_idp_hint", value: "corpx" }], + }), + ); + + onOAuthChange.mockClear(); + const removeButton = screen + .getAllByRole("button") + .find((b) => b.textContent === "X"); + await user.click(removeButton as HTMLElement); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ authorizationParams: [] }), + ); + }); + + it("clears an authorization-parameter key via its Clear button", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + onOAuthChange={onOAuthChange} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }], + }} + expandedSections={["oauth"]} + />, + ); + await user.click(clearButtonFor(screen.getByDisplayValue("kc_idp_hint"))); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + authorizationParams: [{ key: "", value: "corp" }], + }), + ); + + onOAuthChange.mockClear(); + await user.click(clearButtonFor(screen.getByDisplayValue("corp"))); + expect(onOAuthChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + authorizationParams: [{ key: "kc_idp_hint", value: "" }], + }), + ); + }); + + it("rejects a reserved authorization-parameter key inline and with a warning", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "state", value: "spoofed" }], + }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByText( + '"state" is set by the authorization flow and cannot be overridden.', + ), + ).toBeInTheDocument(); + expect(screen.getByText("Reserved parameters ignored")).toBeInTheDocument(); + expect(screen.getByDisplayValue("state")).toHaveAttribute( + "aria-invalid", + "true", + ); + }); + + it("does not flag a blank authorization-parameter row as reserved", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "", value: "" }], + }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.queryByText("Reserved parameters ignored"), + ).not.toBeInTheDocument(); + }); + + it("pluralizes the reserved-parameter warning for multiple keys", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [ + { key: "state", value: "x" }, + { key: "scope", value: "y" }, + ], + }} + expandedSections={["oauth"]} + />, + ); + expect(screen.getByText(/state, scope are set/)).toBeInTheDocument(); + }); + it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => { const user = userEvent.setup(); const onOAuthChange = vi.fn(); @@ -744,6 +899,7 @@ describe("ServerSettingsForm", () => { clientId: "", clientSecret: "", scopes: "", + authorizationParams: [], enterpriseManaged: true, }); }); @@ -848,6 +1004,7 @@ describe("ServerSettingsForm", () => { clientId: "", clientSecret: "z", scopes: "", + authorizationParams: [], enterpriseManaged: false, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index e20ddd44..6a3bee1c 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -1,6 +1,7 @@ import { Accordion, ActionIcon, + Alert, Button, Checkbox, Flex, @@ -26,6 +27,11 @@ import { MODERN_LOG_LEVELS, } from "@inspector/core/mcp/types.js"; import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js"; +import { + RESERVED_AUTHORIZATION_PARAMS, + authorizationParamKeyError, + isReservedAuthorizationParam, +} from "@inspector/core/auth/authorizationParams.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; import type { Root } from "@modelcontextprotocol/client"; @@ -184,6 +190,23 @@ const EmptyHint = Text.withProps({ fs: "italic", }); +// Matches the label/description pair Mantine renders for a single input, so the +// authorization-parameter row list reads as one labelled field next to Scopes. +const FieldLabel = Text.withProps({ + size: "sm", + fw: 500, +}); + +const FieldDescription = Text.withProps({ + size: "xs", + c: "dimmed", +}); + +const ReservedParamError = Text.withProps({ + size: "xs", + c: "var(--inspector-danger-text)", +}); + const ClearStoredOAuthButton = Button.withProps({ variant: "light", color: "red", @@ -245,6 +268,63 @@ function KeyValueRows({ ); } +// Reserved-key rejection is inline per row (#2018): the key input goes red with +// the reason under it, and the section-level warning below repeats it so the +// rejection is visible even when the offending row is scrolled out of view. +function AuthorizationParamRows({ + params, + onChange, + onRemove, +}: { + params: { key: string; value: string }[]; + onChange: (index: number, key: string, value: string) => void; + onRemove: (index: number) => void; +}) { + return ( + <> + {params.map((param, index) => ( + <Stack key={index} gap={4}> + <Group grow> + <ClearableTextInput + placeholder="Parameter (e.g. kc_idp_hint)" + value={param.key} + error={authorizationParamKeyError(param.key) !== undefined} + onChange={(e) => + onChange(index, e.currentTarget.value, param.value) + } + rightSection={ + param.key ? ( + <ClearButton + onClick={() => onChange(index, "", param.value)} + /> + ) : null + } + /> + <ClearableTextInput + placeholder="Value" + value={param.value} + onChange={(e) => + onChange(index, param.key, e.currentTarget.value) + } + rightSection={ + param.value ? ( + <ClearButton onClick={() => onChange(index, param.key, "")} /> + ) : null + } + /> + <RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon> + </Group> + {authorizationParamKeyError(param.key) ? ( + <ReservedParamError> + {authorizationParamKeyError(param.key)} + </ReservedParamError> + ) : null} + </Stack> + ))} + </> + ); +} + function RootRows({ roots, onChange, @@ -374,16 +454,52 @@ export function ServerSettingsForm({ onTimeoutChange(field, numValue); }; + // #2018 — custom authorization-request parameters. The rows ride the single + // `onOAuthChange` callback with the rest of the Authorization section rather + // than growing three more props on this form. + const authorizationParams = settings.oauthAuthorizationParams ?? []; + function currentOAuth(): OAuthSettings { return { clientId: settings.oauthClientId ?? "", clientSecret: settings.oauthClientSecret ?? "", scopes: settings.oauthScopes ?? "", + authorizationParams, enterpriseManaged: settings.enterpriseManaged ?? false, onInsufficientScope: settings.oauthOnInsufficientScope, }; } + const rejectedParamKeys = authorizationParams + .map((p) => p.key.trim()) + .filter((key) => isReservedAuthorizationParam(key)); + + function changeAuthorizationParams( + params: { key: string; value: string }[], + ): void { + onOAuthChange({ ...currentOAuth(), authorizationParams: params }); + } + + function handleAddAuthorizationParam(): void { + changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]); + } + + function handleRemoveAuthorizationParam(index: number): void { + changeAuthorizationParams( + authorizationParams.filter((_, i) => i !== index), + ); + } + + function handleAuthorizationParamChange( + index: number, + key: string, + value: string, + ): void { + changeAuthorizationParams( + authorizationParams.map((p, i) => (i === index ? { key, value } : p)), + ); + } + return ( // Stays inline: Accordion is a compound, `multiple`-discriminated generic, // so `.withProps({ multiple: true, ... })` loses its JSX call signature @@ -699,6 +815,38 @@ export function ServerSettingsForm({ ) : null } /> + <Stack gap="xs"> + <FieldLabel>Additional authorization parameters</FieldLabel> + <FieldDescription> + Extra query parameters appended to the authorization request + only — never the token request. Use them for provider-specific + hints such as kc_idp_hint, login_hint, prompt, acr_values, or + audience. + </FieldDescription> + {authorizationParams.length === 0 ? ( + <EmptyHint>No additional parameters configured</EmptyHint> + ) : ( + <AuthorizationParamRows + params={authorizationParams} + onChange={handleAuthorizationParamChange} + onRemove={handleRemoveAuthorizationParam} + /> + )} + {rejectedParamKeys.length > 0 ? ( + <Alert color="red" title="Reserved parameters ignored"> + {`${rejectedParamKeys.join(", ")} ${ + rejectedParamKeys.length === 1 ? "is" : "are" + } set by the authorization flow and will not be sent. Overriding ${ + rejectedParamKeys.length === 1 ? "it" : "them" + } would break PKCE, the CSRF state binding, or the resource indicator. Reserved: ${RESERVED_AUTHORIZATION_PARAMS.join( + ", ", + )}.`} + </Alert> + ) : null} + <AddButton onClick={handleAddAuthorizationParam}> + + Add Parameter + </AddButton> + </Stack> <Select label="Insufficient-scope response" description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you." diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index ac68ef1e..a6faac5e 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -440,10 +440,35 @@ describe("ServerSettingsModal", () => { oauthClientId: "", oauthClientSecret: "", oauthScopes: "", + oauthAuthorizationParams: [], enterpriseManaged: true, }); }); + // #2018 — the authorization-parameter rows ride the same onOAuthChange + // callback, so the modal folds them onto the settings object. + it("persists an added authorization-parameter row onto the settings", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + <ServerSettingsModal + opened + settings={emptySettings} + serverType="streamable-http" + isStdio={false} + onClose={vi.fn()} + onSettingsChange={onSettingsChange} + />, + ); + await user.click(screen.getByRole("button", { name: "OAuth Settings" })); + await user.click(screen.getByRole("button", { name: "+ Add Parameter" })); + expect(onSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ + oauthAuthorizationParams: [{ key: "", value: "" }], + }), + ); + }); + it("calls onSettingsChange with a blank row when adding a root", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index e391c240..869291b1 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -179,6 +179,10 @@ export function ServerSettingsModal({ oauthClientId: oauth.clientId, oauthClientSecret: oauth.clientSecret, oauthScopes: oauth.scopes, + // #2018: kept as rows (blank ones included) so a half-typed parameter + // survives a re-render; the drop-blank/omit-empty filtering happens on the + // way to disk. An emptied list persists as `[]`, which writes nothing. + oauthAuthorizationParams: oauth.authorizationParams, enterpriseManaged: oauth.enterpriseManaged ? true : undefined, // SEP-2350: persist only the non-default ('throw') so unset servers keep // the SDK's `reauthorize` behavior without writing a spurious field. diff --git a/clients/web/src/test/core/auth/authorizationParams.test.ts b/clients/web/src/test/core/auth/authorizationParams.test.ts new file mode 100644 index 00000000..03f6b0b4 --- /dev/null +++ b/clients/web/src/test/core/auth/authorizationParams.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + RESERVED_AUTHORIZATION_PARAMS, + applyAuthorizationParams, + authorizationParamKeyError, + isReservedAuthorizationParam, +} from "@inspector/core/auth/authorizationParams.js"; + +const AUTHORIZE_URL = + "https://as.example.com/authorize?client_id=abc&response_type=code&state=xyz"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("isReservedAuthorizationParam", () => { + it.each(RESERVED_AUTHORIZATION_PARAMS)("reserves %s", (key) => { + expect(isReservedAuthorizationParam(key)).toBe(true); + }); + + it("ignores case and surrounding whitespace", () => { + expect(isReservedAuthorizationParam(" Client_ID ")).toBe(true); + }); + + it("allows a provider-specific parameter", () => { + expect(isReservedAuthorizationParam("kc_idp_hint")).toBe(false); + }); +}); + +describe("authorizationParamKeyError", () => { + it("returns no error for a blank key (a half-edited row)", () => { + expect(authorizationParamKeyError("")).toBeUndefined(); + expect(authorizationParamKeyError(" ")).toBeUndefined(); + }); + + it("returns no error for an allowed key", () => { + expect(authorizationParamKeyError("login_hint")).toBeUndefined(); + }); + + it("names the offending key for a reserved one", () => { + expect(authorizationParamKeyError(" state ")).toBe( + '"state" is set by the authorization flow and cannot be overridden.', + ); + }); +}); + +describe("applyAuthorizationParams", () => { + it("returns the same URL instance when no params are configured", () => { + const url = new URL(AUTHORIZE_URL); + expect(applyAuthorizationParams(url, undefined)).toBe(url); + }); + + it("returns the same URL instance when the params object is empty", () => { + const url = new URL(AUTHORIZE_URL); + expect(applyAuthorizationParams(url, {})).toBe(url); + }); + + it("appends custom params without mutating the input URL", () => { + const url = new URL(AUTHORIZE_URL); + const merged = applyAuthorizationParams(url, { + kc_idp_hint: "corp-idp", + login_hint: "user@example.com", + }); + + expect(merged).not.toBe(url); + expect(url.searchParams.get("kc_idp_hint")).toBeNull(); + expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(merged.searchParams.get("login_hint")).toBe("user@example.com"); + // The SDK-built parameters survive untouched. + expect(merged.searchParams.get("client_id")).toBe("abc"); + expect(merged.searchParams.get("state")).toBe("xyz"); + }); + + it("trims the key and preserves the value verbatim", () => { + const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), { + " prompt ": " login ", + }); + expect(merged.searchParams.get("prompt")).toBe(" login "); + }); + + it("skips blank keys", () => { + const url = new URL(AUTHORIZE_URL); + const merged = applyAuthorizationParams(url, { " ": "nope" }); + expect(merged).toBe(url); + }); + + it("drops a reserved key with a warning rather than overriding it", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), { + state: "attacker-controlled", + kc_idp_hint: "corp-idp", + }); + + expect(merged.searchParams.get("state")).toBe("xyz"); + expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("state"); + }); + + it("returns the original URL when every configured key is reserved", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const url = new URL(AUTHORIZE_URL); + expect( + applyAuthorizationParams(url, { + Client_Id: "spoofed", + code_challenge: "spoofed", + }), + ).toBe(url); + expect(warn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index cde4faa7..7cf3acce 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { OAuthTokens } from "@modelcontextprotocol/client"; -import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; +import { + BaseOAuthClientProvider, + CallbackNavigation, + MutableRedirectUrlProvider, +} from "@inspector/core/auth/providers.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js"; import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js"; import { @@ -39,6 +44,7 @@ interface FakeInner { tokens: ReturnType<typeof vi.fn>; saveTokens: ReturnType<typeof vi.fn>; redirectToAuthorization: ReturnType<typeof vi.fn>; + redirectToExternalAuthorization: ReturnType<typeof vi.fn>; clearCapturedAuthUrl: ReturnType<typeof vi.fn>; saveCodeVerifier: ReturnType<typeof vi.fn>; codeVerifier: ReturnType<typeof vi.fn>; @@ -55,6 +61,7 @@ function createInner(): FakeInner { tokens: vi.fn(), saveTokens: vi.fn(), redirectToAuthorization: vi.fn(), + redirectToExternalAuthorization: vi.fn(), clearCapturedAuthUrl: vi.fn(), saveCodeVerifier: vi.fn(), codeVerifier: vi.fn(() => "verifier-xyz"), @@ -178,6 +185,42 @@ describe("EmaTransportOAuthProvider", () => { expect(startIdpMock).toHaveBeenCalledWith(emaConfig); expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1); - expect(inner.redirectToAuthorization).toHaveBeenCalledWith(idpUrl); + expect(inner.redirectToExternalAuthorization).toHaveBeenCalledWith(idpUrl); + // The custom-parameter merge lives on `redirectToAuthorization`, so the EMA + // leg must never take that path. + expect(inner.redirectToAuthorization).not.toHaveBeenCalled(); + }); + + // #2018 — the inner provider carries the per-server custom authorization + // parameters, which belong to the MCP server's authorization server. The EMA + // leg sends the user to a *different* authorization server (the enterprise + // IdP), so those parameters must not reach it. Driven against a real + // BaseOAuthClientProvider rather than the fake inner above, since the defect + // is precisely in what the real provider does with the URL it is handed. + it("does not leak custom authorization params onto the IdP authorize URL", async () => { + const navCallback = vi.fn(); + const realInner = new BaseOAuthClientProvider(SERVER_URL, { + storage: { + getScope: vi.fn().mockResolvedValue(undefined), + } as unknown as OAuthStorage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(navCallback), + authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" }, + }); + const emaProvider = new EmaTransportOAuthProvider(realInner, emaConfig); + + const idpUrl = new URL("https://idp.test/authorize?state=abc"); + startIdpMock.mockResolvedValue(idpUrl); + + await emaProvider.redirectToAuthorization( + new URL("https://resource-as.test/authorize"), + ); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("kc_idp_hint")).toBeNull(); + expect(navigated.searchParams.get("audience")).toBeNull(); + expect(navigated.href).toBe(idpUrl.href); + // The captured URL (what the step-up UI shows) must agree. + expect(realInner.getCapturedAuthUrl()?.href).toBe(idpUrl.href); }); }); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 34e59975..67b2f9c7 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -352,6 +352,68 @@ describe("OAuthNavigation", () => { expect(navCallback).toHaveBeenCalledWith(url); }); + // #2018 — the provider is the seam where per-server custom parameters reach + // the authorize URL, since the SDK builds that URL with no hook of its own. + function makeProviderWithParams( + storage: OAuthStorage, + authorizationParams: Record<string, string>, + navCallback = vi.fn(), + ): BaseOAuthClientProvider { + return new BaseOAuthClientProvider(SERVER, { + storage, + redirectUrlProvider: new MutableRedirectUrlProvider(), + navigation: new CallbackNavigation(navCallback), + authorizationParams, + }); + } + + it("redirectToAuthorization merges configured authorization params", () => { + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProviderWithParams( + storage, + { kc_idp_hint: "corp-idp" }, + navCallback, + ); + const target = new EventTarget(); + const handler = vi.fn(); + target.addEventListener("oauthAuthorizationRequired", handler); + provider.setEventTarget(target); + + const url = new URL("https://mcp.example.com/authorize?client_id=abc"); + provider.redirectToAuthorization(url); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(navigated.searchParams.get("client_id")).toBe("abc"); + // The captured URL, the event detail, and the navigation all agree. + expect(provider.getCapturedAuthUrl()?.href).toBe(navigated.href); + const detail = (handler.mock.calls[0]?.[0] as CustomEvent<{ url: URL }>) + .detail; + expect(detail.url.href).toBe(navigated.href); + // The URL the SDK handed in is left untouched. + expect(url.searchParams.get("kc_idp_hint")).toBeNull(); + }); + + it("redirectToAuthorization refuses a reserved authorization param", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const storage = makeStorage(); + const navCallback = vi.fn(); + const provider = makeProviderWithParams( + storage, + { state: "spoofed" }, + navCallback, + ); + + const url = new URL("https://mcp.example.com/authorize?state=real"); + provider.redirectToAuthorization(url); + + const navigated = navCallback.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("state")).toBe("real"); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + it("delegates token and scope persistence to storage", async () => { const storage = makeStorage(); const provider = makeProvider(storage); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index 2b3ee568..3936d6c2 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -86,6 +86,44 @@ describe("runner client auth options", () => { expect(opts.oauth?.clientId).toBe("resource-client"); }); + // #2018 — the CLI/TUI leg picks the authorization parameters up from the same + // per-server settings the web leg reads, so all three clients agree. + it("buildRunnerClientAuthOptions forwards custom authorization params", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "dropped" }, + ], + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + const opts = buildRunnerClientAuthOptions({}, settings); + expect(opts.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" }); + }); + + it("buildRunnerClientAuthOptions ignores all-blank authorization param rows", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationParams: [{ key: " ", value: "x" }], + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + expect(buildRunnerClientAuthOptions({}, settings)).toEqual({}); + }); + it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => { expect(buildRunnerClientAuthOptions({})).toEqual({}); }); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index c5eee523..824419b5 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -10,6 +10,7 @@ import { mcpConfigToServerEntries, mergeSecretsIntoStored, normalizeServerType, + oauthAuthorizationParamsFromSettings, serverEntriesToMcpConfig, serializeMcpConfig, storedFieldsToInspectorSettings, @@ -924,6 +925,21 @@ describe("extractSecretsFromStored", () => { expect(stripped.oauth).toEqual({ enterpriseManaged: true }); }); + it("preserves oauth.authorizationParams when lifting clientSecret to keychain", () => { + const stored: StoredMCPServer = { + type: "streamable-http", + url: "https://x.test", + oauth: { + clientSecret: "shh", + authorizationParams: { kc_idp_hint: "corp" }, + }, + }; + const { stripped } = extractSecretsFromStored(stored); + expect(stripped.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + }); + it("removes the oauth block entirely when clientSecret was its only property", () => { const stored: StoredMCPServer = { type: "streamable-http", @@ -1188,6 +1204,68 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { }); expect(stored.oauth?.onInsufficientScope).toBeUndefined(); }); + + // #2018 — custom authorization-request parameters. + it("lifts oauth.authorizationParams into settings rows", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { authorizationParams: { kc_idp_hint: "corp", prompt: "login" } }, + }); + expect(settings?.oauthAuthorizationParams).toEqual([ + { key: "kc_idp_hint", value: "corp" }, + { key: "prompt", value: "login" }, + ]); + }); + + it("reads an empty authorizationParams object back as unset", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", authorizationParams: {} }, + }); + expect(settings?.oauthAuthorizationParams).toBeUndefined(); + }); + + it("persists authorizationParams under oauth, dropping blank-key rows", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: " ", value: "orphan" }, + ], + }); + expect(stored.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" }); + }); + + it("omits the oauth block when every authorization-param row is blank", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationParams: [{ key: "", value: "" }], + }); + expect(stored).not.toHaveProperty("oauth"); + }); + + it("omits authorizationParams when the rows are absent", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthClientId: "cid", + }); + expect(stored.oauth?.authorizationParams).toBeUndefined(); + }); + + it("derives the client option record, or undefined when nothing survives", () => { + expect( + oauthAuthorizationParamsFromSettings({ + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "x" }, + ], + }), + ).toEqual({ kc_idp_hint: "corp" }); + expect( + oauthAuthorizationParamsFromSettings({ + oauthAuthorizationParams: [{ key: " ", value: "x" }], + }), + ).toBeUndefined(); + expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined(); + }); }); describe("envRecordToPairs / envPairsToRecord", () => { diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 613d4c6a..9a44f07c 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -701,6 +701,58 @@ describe("server.ts supplemental coverage", () => { await stop(h); } }); + + // #2018 — authorizationParams must be a string record; a hand-edited file + // with anything else drops the whole `oauth` node rather than feeding + // non-string values to the authorize-URL merge. + it("drops oauth whose authorizationParams is not a string record", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { authorizationParams: { kc_idp_hint: 5 } }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + expect(res.status).toBe(200); + const body = (await res.json()) as { + mcpServers: Record<string, Record<string, unknown>>; + }; + expect(body.mcpServers.srv).not.toHaveProperty("oauth"); + } finally { + await stop(h); + } + }); + + it("keeps a well-formed authorizationParams record on read", async () => { + const h = await start({ + seedConfig: JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { authorizationParams: { kc_idp_hint: "corp" } }, + }, + }, + }), + }); + try { + const res = await fetch(`${h.baseUrl}/api/servers`); + const body = (await res.json()) as { + mcpServers: Record<string, Record<string, unknown>>; + }; + expect(body.mcpServers.srv?.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + } finally { + await stop(h); + } + }); }); describe("plaintext-secret migration over a mixed config", () => { diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 2bd71568..9e0f8a5a 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -449,6 +449,54 @@ describe("/api/servers routes", () => { }); }); + // #2018 — the authorization-parameter rows travel as a pair array on the + // wire and land as a record under `oauth` on disk, mirroring `headers`. + it("persists custom authorization params under oauth on POST", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "authparams", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationParams: [ + { key: "kc_idp_hint", value: "corp" }, + { key: "", value: "dropped" }, + ], + }, + }), + }); + expect(res.status).toBe(200); + const stored = readConfig(h.configPath).mcpServers + .authparams as unknown as Record<string, unknown>; + expect(stored.oauth).toEqual({ + authorizationParams: { kc_idp_hint: "corp" }, + }); + }); + + it("rejects a malformed oauthAuthorizationParams payload", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "badauthparams", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationParams: { kc_idp_hint: "corp" }, + }, + }), + }); + expect(res.status).toBe(400); + }); + it("updates Inspector-extension fields at the top level on PUT", async () => { writeFileSync( h.configPath, diff --git a/core/auth/authorizationParams.ts b/core/auth/authorizationParams.ts new file mode 100644 index 00000000..ee121de8 --- /dev/null +++ b/core/auth/authorizationParams.ts @@ -0,0 +1,105 @@ +/** + * Per-server custom authorization-request parameters (#2018). + * + * Real deployments gate IdP selection, prompt behavior, or audience on a query + * parameter the OAuth/OIDC core specs never standardized — Keycloak's + * `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's + * `audience`. The Inspector never builds the authorize URL itself (the SDK's + * `auth()` does, and exposes no hook for extra parameters), so the merge + * happens at the one seam that sees the finished URL: + * `BaseOAuthClientProvider.redirectToAuthorization`. + * + * The parameters apply to the **authorization request only** — never the token + * request, which the SDK builds separately and which this module is not wired + * into. They also apply only to the server's **own** authorization server: + * the enterprise-managed (EMA) leg redirects to a different one (the enterprise + * IdP) and goes through `BaseOAuthClientProvider.redirectToExternalAuthorization`, + * which deliberately skips the merge. + */ + +/** + * Parameters the OAuth flow owns and a user may not override. + * + * Overriding any of these does not produce a useful debugging scenario — it + * breaks PKCE (`code_challenge`, `code_challenge_method`), CSRF binding + * (`state`), the callback leg (`redirect_uri`, `response_type`, `client_id`), + * or RFC 8707 resource indicators (`resource`), and the failure surfaces as an + * opaque authorization-server error rather than as an Inspector problem. + * `scope` is excluded too: it has its own field, and the SEP-2350 step-up union + * is computed from that field rather than from these parameters. + */ +export const RESERVED_AUTHORIZATION_PARAMS = [ + "client_id", + "code_challenge", + "code_challenge_method", + "redirect_uri", + "resource", + "response_type", + "scope", + "state", +] as const; + +export type ReservedAuthorizationParam = + (typeof RESERVED_AUTHORIZATION_PARAMS)[number]; + +const RESERVED: ReadonlySet<string> = new Set(RESERVED_AUTHORIZATION_PARAMS); + +/** + * Whether `key` names a parameter the OAuth flow owns. Compared case- and + * whitespace-insensitively: query parameter names are case-sensitive on the + * wire, but a `Client_Id` typed into the form is plainly an attempt to set the + * reserved one, and letting it through would be the silent override this rule + * exists to prevent. + */ +export function isReservedAuthorizationParam(key: string): boolean { + return RESERVED.has(key.trim().toLowerCase()); +} + +/** + * Validation message for one authorization-parameter key, or `undefined` when + * the key is acceptable. A blank key is not an error — the form lets a user + * leave a freshly-added row empty mid-edit, and blank rows are dropped rather + * than rejected. + */ +export function authorizationParamKeyError(key: string): string | undefined { + const trimmed = key.trim(); + if (trimmed === "") return undefined; + if (isReservedAuthorizationParam(trimmed)) { + return `"${trimmed}" is set by the authorization flow and cannot be overridden.`; + } + return undefined; +} + +/** + * Merge custom parameters into a finished authorization URL. + * + * Blank keys are skipped (a half-edited form row) and reserved keys are dropped + * with a warning — never silently, and never overriding what the SDK put there. + * Returns the original URL instance when there is nothing to apply, and + * otherwise a copy, so the caller's URL is never mutated. + */ +export function applyAuthorizationParams( + authorizationUrl: URL, + params: Record<string, string> | undefined, +): URL { + if (!params) return authorizationUrl; + + const applicable = Object.entries(params).filter(([key]) => { + if (key.trim() === "") return false; + if (isReservedAuthorizationParam(key)) { + console.warn( + `[oauth] Ignoring reserved authorization parameter "${key.trim()}" — it is set by the authorization flow.`, + ); + return false; + } + return true; + }); + + if (applicable.length === 0) return authorizationUrl; + + const merged = new URL(authorizationUrl.href); + for (const [key, value] of applicable) { + merged.searchParams.set(key.trim(), value); + } + return merged; +} diff --git a/core/auth/ema/transportProvider.ts b/core/auth/ema/transportProvider.ts index b130fff9..fc36ea10 100644 --- a/core/auth/ema/transportProvider.ts +++ b/core/auth/ema/transportProvider.ts @@ -84,7 +84,11 @@ export class EmaTransportOAuthProvider implements OAuthClientProvider { async redirectToAuthorization(_authorizationUrl: URL): Promise<void> { const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig); this.inner.clearCapturedAuthUrl(); - this.inner.redirectToAuthorization(idpAuthorizationUrl); + // The IdP is a different authorization server than the one the inner + // provider was configured for, so the per-server custom authorization + // parameters (#2018) must not be appended here — hence the "external" + // entry point rather than `redirectToAuthorization`. + this.inner.redirectToExternalAuthorization(idpAuthorizationUrl); } saveCodeVerifier(codeVerifier: string): void | Promise<void> { diff --git a/core/auth/index.ts b/core/auth/index.ts index 6086d7ac..53615f8c 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -27,6 +27,15 @@ export { ensureCimdClientRegistration } from "./cimd.js"; export { mcpAuth, type McpAuthOptions, type McpAuthResult } from "./mcpAuth.js"; export { computeScopeUnion, isStrictScopeSuperset } from "./scopes.js"; +// Custom authorization-request parameters (#2018) +export { + RESERVED_AUTHORIZATION_PARAMS, + isReservedAuthorizationParam, + authorizationParamKeyError, + applyAuthorizationParams, +} from "./authorizationParams.js"; +export type { ReservedAuthorizationParam } from "./authorizationParams.js"; + // Storage export type { OAuthStorage, diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 12808542..0c492064 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -11,6 +11,7 @@ import type { } from "@modelcontextprotocol/client"; import type { OAuthStorage, SaveClientInformationOptions } from "./storage.js"; import { generateOAuthState } from "./utils.js"; +import { applyAuthorizationParams } from "./authorizationParams.js"; /** * Redirect URL provider. Returns the redirect URL for OAuth flows. @@ -96,6 +97,14 @@ export type OAuthProviderConfig = { redirectUrlProvider: RedirectUrlProvider; navigation: OAuthNavigation; clientMetadataUrl?: string; + /** + * Per-server custom authorization-request parameters (#2018), merged into the + * finished authorize URL in {@link BaseOAuthClientProvider.redirectToAuthorization}. + * Reserved (protocol-critical) keys are dropped with a warning — see + * `authorizationParams.ts`. Authorization request only; the token request is + * unaffected. + */ + authorizationParams?: Record<string, string>; }; /** @@ -115,6 +124,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { protected redirectUrlProvider: RedirectUrlProvider; protected navigation: OAuthNavigation; public clientMetadataUrl?: string; + /** Custom authorization-request parameters (#2018). Authorize URL only. */ + protected authorizationParams?: Record<string, string>; constructor(serverUrl: string, oauthConfig: OAuthProviderConfig) { this.serverUrl = serverUrl; @@ -122,6 +133,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.redirectUrlProvider = oauthConfig.redirectUrlProvider; this.navigation = oauthConfig.navigation; this.clientMetadataUrl = oauthConfig.clientMetadataUrl; + this.authorizationParams = oauthConfig.authorizationParams; } /** @@ -272,6 +284,34 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } redirectToAuthorization(authorizationUrl: URL): void { + // #2018: the SDK builds the authorize URL and offers no hook for extra + // parameters, so the per-server ones are merged here — the one seam that + // sees the finished URL before navigation. Everything downstream (the + // captured URL used by the step-up modal, the event, the navigation) uses + // the merged URL so all three agree on what was actually requested. + this.performAuthorizationRedirect( + applyAuthorizationParams(authorizationUrl, this.authorizationParams), + ); + } + + /** + * Redirect to an authorize URL belonging to a **different** authorization + * server than the one this provider was configured for, so the per-server + * custom parameters (#2018) MUST NOT be applied. + * + * The only caller is the enterprise-managed (EMA) transport provider, which + * discards the resource AS URL and sends the user to the enterprise IdP + * instead. Parameters a user configured for their MCP server's authorization + * server — a `kc_idp_hint`, an `audience` — are meaningless and potentially + * flow-breaking on the IdP's, and appending them there would leak per-server + * config across an authorization-server boundary. + */ + redirectToExternalAuthorization(authorizationUrl: URL): void { + this.performAuthorizationRedirect(authorizationUrl); + } + + /** Capture, announce, and navigate to a finished authorize URL. */ + private performAuthorizationRedirect(authorizationUrl: URL): void { // Capture URL for return value this.capturedAuthUrl = authorizationUrl; diff --git a/core/client/runner.ts b/core/client/runner.ts index e61a461a..25cd83f3 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -11,6 +11,7 @@ import type { InspectorClientOptions, InspectorServerSettings, } from "../mcp/types.js"; +import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js"; import { loadClientConfig } from "./config.js"; import type { ClientConfig } from "./types.js"; import { @@ -70,11 +71,16 @@ export function buildRunnerClientAuthOptions( const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig); const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig); + const serverAuthorizationParams = savedSettings + ? oauthAuthorizationParamsFromSettings(savedSettings) + : undefined; + const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || + serverAuthorizationParams || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -86,6 +92,9 @@ export function buildRunnerClientAuthOptions( ...(savedSettings.oauthScopes && { scope: savedSettings.oauthScopes, }), + ...(serverAuthorizationParams && { + authorizationParams: serverAuthorizationParams, + }), ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b0b809f3..37d9e2cc 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -5421,6 +5421,8 @@ export class InspectorClient extends InspectorClientEventTarget { clientSecret?: string; clientMetadataUrl?: string; scope?: string; + /** Custom authorization-request parameters (#2018). Authorize URL only. */ + authorizationParams?: Record<string, string>; }): void { if (!this.oauthManager) { throw new Error( diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 3decd17f..f49aff49 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -94,6 +94,7 @@ export class OAuthManager { clientMetadataUrl?: string; scope?: string; enterpriseManaged?: boolean; + authorizationParams?: Record<string, string>; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -135,6 +136,11 @@ export class OAuthManager { redirectUrlProvider: this.oauthConfig.redirectUrlProvider, navigation: this.oauthConfig.navigation, clientMetadataUrl: this.oauthConfig.clientMetadataUrl, + // #2018: per-server custom authorization-request parameters. Carried on + // the provider (not in OAuth storage like `scope`) — they are pure config + // read from mcp.json, and nothing in the flow needs to persist or union + // them. + authorizationParams: this.oauthConfig.authorizationParams, }); provider.setEventTarget(this.params.getEventTarget()); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 9d765548..2489a521 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1130,6 +1130,7 @@ export function createRemoteApp( clientId?: string; clientSecret?: string; scopes?: string; + authorizationParams?: Record<string, string>; enterpriseManaged?: boolean; } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; @@ -1137,6 +1138,12 @@ export function createRemoteApp( for (const k of ["clientId", "clientSecret", "scopes"] as const) { if (o[k] !== undefined && typeof o[k] !== "string") return false; } + if ( + o.authorizationParams !== undefined && + !isStringRecord(o.authorizationParams) + ) { + return false; + } if ( o.enterpriseManaged !== undefined && typeof o.enterpriseManaged !== "boolean" @@ -1248,7 +1255,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.", ); delete valObj.oauth; } @@ -1520,6 +1527,18 @@ export function createRemoteApp( return { ok: false, error: `settings.${optional} must be a string` }; } } + // Optional on the wire (older clients won't send it); when present it must + // be the same `{ key, value }` row shape the headers/metadata lists use. + if ( + obj.oauthAuthorizationParams !== undefined && + !isKvArray(obj.oauthAuthorizationParams) + ) { + return { + ok: false, + error: + "settings.oauthAuthorizationParams must be an array of { key, value }", + }; + } if ( obj.enterpriseManaged !== undefined && typeof obj.enterpriseManaged !== "boolean" @@ -1635,6 +1654,12 @@ export function createRemoteApp( if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") { value.oauthScopes = obj.oauthScopes; } + // Carried through with its blank rows intact — the omit-on-empty filtering + // happens on the way to disk (inspectorSettingsToStoredFields), matching + // how the headers/metadata rows are handled. (#2018) + if (isKvArray(obj.oauthAuthorizationParams)) { + value.oauthAuthorizationParams = obj.oauthAuthorizationParams; + } if (obj.enterpriseManaged === true) { value.enterpriseManaged = true; } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 56aaae5e..0c3c759e 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -164,6 +164,9 @@ type StoredInspectorFields = Pick< * Convert a stored stdio `env` record into the controlled key/value rows the * settings form edits, preserving the object's key insertion order. Empty when * absent. Inverse of `envPairsToRecord`. + * + * The transform is generic key/value, so the OAuth authorization-parameters + * record (#2018) reuses it rather than growing a second copy. */ export function envRecordToPairs( env: Record<string, string> | undefined, @@ -176,6 +179,9 @@ export function envRecordToPairs( * with an empty/whitespace key (the form lets users leave a new row blank * mid-edit). Inverse of `envRecordToPairs`. Used by the `/api/servers` PUT * write-through that maps `settings.env` back onto `config.env`. + * + * Generic key/value like its inverse, so the OAuth authorization-parameters + * rows (#2018) collapse through it too. */ export function envPairsToRecord( pairs: { key: string; value: string }[], @@ -188,6 +194,21 @@ export function envPairsToRecord( return out; } +/** + * Collapse a server's custom authorization-parameter rows into the record the + * `InspectorClientOptions.oauth.authorizationParams` option takes, or + * `undefined` when nothing survives (no rows, or every row blank-keyed). Shared + * by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths so both + * derive the option identically. (#2018) + */ +export function oauthAuthorizationParamsFromSettings( + settings: Pick<InspectorServerSettings, "oauthAuthorizationParams">, +): Record<string, string> | undefined { + if (!settings.oauthAuthorizationParams) return undefined; + const record = envPairsToRecord(settings.oauthAuthorizationParams); + return Object.keys(record).length > 0 ? record : undefined; +} + /** * Lift the Inspector-extension fields off a freshly-read `StoredMCPServer` * into the pair-array / flat-OAuth `InspectorServerSettings` shape the form @@ -286,6 +307,17 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.clientSecret) settings.oauthClientSecret = stored.oauth.clientSecret; if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes; + // Optional record with no default; carried through only when the file has a + // non-empty object, so an absent/empty field reads back as unset and the + // write side then omits it — keeping a byte-stable round-trip. (#2018) + if ( + stored.oauth?.authorizationParams && + Object.keys(stored.oauth.authorizationParams).length > 0 + ) { + settings.oauthAuthorizationParams = envRecordToPairs( + stored.oauth.authorizationParams, + ); + } if (stored.oauth?.onInsufficientScope) { settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope; } @@ -395,6 +427,14 @@ export function inspectorSettingsToStoredFields( if (settings.oauthClientSecret) oauthFields.clientSecret = settings.oauthClientSecret; if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes; + // Blank-key rows (a row the user added but never filled in) are dropped, so + // an all-blank list writes nothing — matching the read side's omit-on-empty. + if (settings.oauthAuthorizationParams) { + const authParams = envPairsToRecord(settings.oauthAuthorizationParams); + if (Object.keys(authParams).length > 0) { + oauthFields.authorizationParams = authParams; + } + } if (settings.oauthOnInsufficientScope) { oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope; } @@ -605,12 +645,17 @@ export function extractSecretsFromStored( const restOauth: { clientId?: string; scopes?: string; + authorizationParams?: Record<string, string>; enterpriseManaged?: boolean; } = {}; if (stored.oauth.clientId !== undefined) restOauth.clientId = stored.oauth.clientId; if (stored.oauth.scopes !== undefined) restOauth.scopes = stored.oauth.scopes; + // Not a secret — the authorization parameters stay on the stripped entry so + // stripping the client secret doesn't silently discard them. (#2018) + if (stored.oauth.authorizationParams !== undefined) + restOauth.authorizationParams = stored.oauth.authorizationParams; if (stored.oauth.enterpriseManaged === true) { restOauth.enterpriseManaged = true; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index b89f117b..32273d7a 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -166,6 +166,16 @@ export type StoredMCPServer = MCPServerConfig & { enterpriseManaged?: boolean; /** SEP-2350 step-up policy for `403 insufficient_scope` (default `reauthorize`). */ onInsufficientScope?: OnInsufficientScopePolicy; + /** + * Custom query parameters appended to the OAuth **authorization request** + * (never the token request) — e.g. Keycloak's `kc_idp_hint`, OIDC's + * `login_hint` / `prompt` / `acr_values`, Auth0's `audience`. Reserved, + * protocol-critical keys (`client_id`, `state`, `code_challenge`, …) are + * rejected by the form and dropped with a warning at merge time. Omitted on + * disk when empty, keeping the file diff minimal. Inspector-specific. + * (#2018) + */ + authorizationParams?: Record<string, string>; }; /** * Filesystem/URI roots advertised to the server via the `roots` client @@ -516,6 +526,14 @@ export interface OAuthSettings { clientId: string; clientSecret: string; scopes: string; + /** + * Custom authorization-request parameters as controlled key/value rows. + * Optional so callers constructing an `OAuthSettings` for the other fields + * don't have to supply an empty array; the form always passes the current + * rows (blank ones included, so a half-typed row survives a re-render). + * (#2018) + */ + authorizationParams?: { key: string; value: string }[]; enterpriseManaged?: boolean; onInsufficientScope?: OnInsufficientScopePolicy; } @@ -674,6 +692,13 @@ export interface InspectorServerSettings { oauthClientId?: string; oauthClientSecret?: string; oauthScopes?: string; + /** + * Custom authorization-request parameters, edited as controlled key/value + * rows (mirrors `headers` / `metadata`). Persisted as the + * `oauth.authorizationParams` record on disk; blank-key rows are dropped on + * the way there. Applied to the authorization request only. (#2018) + */ + oauthAuthorizationParams?: { key: string; value: string }[]; /** * SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this * server's HTTP transport. Defaults to `reauthorize` when unset. @@ -1026,6 +1051,11 @@ export interface InspectorClientOptions { scope?: string; /** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */ enterpriseManaged?: boolean; + /** + * Custom query parameters merged into the authorization request URL only + * (#2018). Reserved, protocol-critical keys are dropped with a warning. + */ + authorizationParams?: Record<string, string>; }; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 00455a24..1fa58a7a 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -175,7 +175,9 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | -| `oauth` | — | `{ clientId, clientSecret, scopes, enterpriseManaged, onInsufficientScope }` | +| `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, enterpriseManaged, onInsufficientScope }` | + +`oauth.authorizationParams` is a string→string record of extra query parameters merged into the OAuth **authorization request** URL only — never the token request. Use it for provider-specific hints the core specs don't standardize (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`). The protocol-critical parameters — `client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state` — are **reserved**: the web form rejects them inline, and any that reach the merge anyway are dropped with a warning rather than overriding what the flow set (overriding them breaks PKCE, the CSRF state binding, or RFC 8707). Edit them in Server Settings → Authorization ("Additional authorization parameters"), beside Scopes. A catalog carrying these fields: From 67972adc4eaf369dee5ddd4655d6955ed772cea2 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:33:27 -0400 Subject: [PATCH 144/941] fix: bound the salvage walks and mark the scan's refused response (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 6. Bound both fallback walks with LIST_MAX_PAGES, which is now also passed to the SDK client as `listMaxPages` so the strict aggregate and its fallback share one value by construction. A server emitting endlessly UNIQUE cursors never trips the repeated-cursor guard, so the salvage re-walk and the SEP-2243 exclusion scan would page and accumulate without bound. Hitting the cap aborts rather than returning a truncated list as a complete one. Mark the exclusion scan's refused response on every fallback-failure path. `salvageList` can rethrow bare because ManagedListState marks from the error it receives; the scan has no such backstop — listAllTools catches and logs a failing scan by design — so an unmarked rethrow left a response the client demonstrably refused rendering as a clean exchange. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/listSalvage.test.ts | 19 +++ .../inspectorClient-malformed-list.test.ts | 88 +++++++++++ core/mcp/inspectorClient.ts | 147 ++++++++++++------ core/mcp/listSalvage.ts | 35 +++++ 4 files changed, 245 insertions(+), 44 deletions(-) diff --git a/clients/web/src/test/core/mcp/listSalvage.test.ts b/clients/web/src/test/core/mcp/listSalvage.test.ts index f547eee0..2cdf3f3d 100644 --- a/clients/web/src/test/core/mcp/listSalvage.test.ts +++ b/clients/web/src/test/core/mcp/listSalvage.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect } from "vitest"; import { z } from "zod/v4"; import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; import { + LIST_MAX_PAGES, ModernResultEnvelopeSchema, describeIssues, + listPaginationExceeded, isClientDecodeRejection, isSalvageableRejection, labelForRawItem, @@ -317,4 +319,21 @@ describe("summarizeMalformed", () => { expect(summary).toContain("index 0"); expect(summary).toContain("(+1 more)"); }); + + describe("the page bound", () => { + it("matches the cap the SDK client is configured with", () => { + // The value is passed to the SDK as `listMaxPages`, so the strict + // aggregate and the fallback walks are bounded by the same number. + expect(LIST_MAX_PAGES).toBe(64); + }); + + it("names the method and the cap it hit", () => { + // The walk's failure is logged rather than surfaced (the original + // validation error is what the caller sees), so the message is the only + // record of why salvage gave up. + const error = listPaginationExceeded("resources/templates/list"); + expect(error.message).toContain("resources/templates/list"); + expect(error.message).toContain("64 pages"); + }); + }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index b9b1f8a6..c9224ff1 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -9,6 +9,7 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { MessageLogState } from "@inspector/core/mcp/state/index.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { LIST_MAX_PAGES } from "@inspector/core/mcp/listSalvage.js"; /** * Per-item list salvage against a real connection (#1909). @@ -608,4 +609,91 @@ describe("InspectorClient list salvage (#1909)", () => { await expect(connected.listAllResourceTemplates()).rejects.toThrow(); expect(connected.getMalformedListItems()).toEqual([]); }); + + it("stops the salvage walk at the page cap instead of paging forever", async () => { + // A server whose `nextCursor` never converges but never REPEATS slips the + // repeated-cursor guard: every cursor is fresh, so the walk had no reason + // to stop and would page (and accumulate) without bound. The bound is the + // same `LIST_MAX_PAGES` the SDK aggregate is configured with. + const server = await startMalformedServer({ + resourceTemplates: Array.from( + { length: LIST_MAX_PAGES + 40 }, + (_, i) => ({ + items: [PHP_EMPTY_ANNOTATIONS], + nextCursor: `cursor-${i}`, + }), + ), + }); + stopServer = server.stop; + const connected = await connectTo(server.url); + + // Truncation is not salvage: with entries still unfetched, returning what + // we have would present a partial list as complete, so the original + // validation error stands. + await expect(connected.listAllResourceTemplates()).rejects.toThrow(); + expect(connected.getMalformedListItems()).toEqual([]); + + const listCalls = server.calls.filter( + (method) => method === "resources/templates/list", + ); + // One strict aggregate attempt (rejected on page one), then a salvage walk + // bounded at exactly the cap. Without the bound this runs to 104 pages — + // and against a truly endless server, forever. + expect(listCalls).toHaveLength(1 + LIST_MAX_PAGES); + }); + + it("stops the exclusion scan at the page cap too", async () => { + // The SEP-2243 scan walks pages of its own, outside the SDK aggregate's + // cap, so it needs the same bound. Driven directly because a non-converging + // list trips `listAllTools`'s salvage walk first. + const server = await startMalformedServer( + { + tools: Array.from({ length: LIST_MAX_PAGES + 40 }, (_, i) => ({ + items: [{ name: `tool_${i}`, inputSchema: { type: "object" } }], + nextCursor: `cursor-${i}`, + })), + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + + await expect(connected.refreshExcludedTools()).rejects.toThrow( + /exceeded 64 pages/, + ); + expect( + server.calls.filter((method) => method === "tools/list"), + ).toHaveLength(LIST_MAX_PAGES); + }); + + it("marks the scan's refused response when its own fallback fails", async () => { + // `tools` is not a list at all, so the scan's lenient re-fetch cannot + // explain the rejection and rethrows. `listAllTools` catches a failing scan + // by design (it must never fail the tools list), so nothing downstream + // would mark the response the client demonstrably refused — it would render + // as a clean exchange. + const server = await startMalformedServer( + { tools: [{ items: "not-a-list" }] }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + const log = new MessageLogState(connected); + + await expect(connected.refreshExcludedTools()).rejects.toThrow(); + + const marked = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "tools/list" && + entry.clientError !== undefined, + ); + // The mark carries the ORIGINAL decode error: the fallback's own failure is + // a different event from the response being marked. + expect(marked).toHaveLength(1); + expect(marked[0]?.clientError).toBeTruthy(); + log.destroy(); + }); }); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cbdba8ba..cc7f0b65 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -172,8 +172,10 @@ import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; import { + LIST_MAX_PAGES, ModernResultEnvelopeSchema, isSalvageableRejection, + listPaginationExceeded, toolItemSchemaForEra, nextCursorOf, lenientListPageSchema, @@ -688,7 +690,13 @@ export class InspectorClient extends InspectorClientEventTarget { capabilities?: ClientCapabilities; versionNegotiation?: VersionNegotiationOptions; inputRequired?: InputRequiredOptions; + listMaxPages?: number; } = { + // Bound the SDK's auto-aggregate with the SAME constant the #1909 salvage + // re-walks use, so the strict path and its fallback cannot drift apart. + // This is the SDK's own default today; setting it explicitly is what makes + // the shared value a fact rather than a copied guess. + listMaxPages: LIST_MAX_PAGES, // Per-server protocol era (SEP §7.8), threaded from config via // `eraToVersionNegotiation` and defaulted to `{ mode: "legacy" }` in the // constructor. "legacy" keeps the wire byte-identical to a 2025 client; @@ -3442,8 +3450,15 @@ export class InspectorClient extends InspectorClientEventTarget { const malformed: MalformedListItem[] = []; const seenCursors = new Set<string>(); let cursor: string | undefined; + let pages = 0; try { do { + // Bounded like the SDK aggregate this stands in for: a server emitting + // endlessly UNIQUE cursors never trips the repeated-cursor guard below, + // so without this the walk would run forever. Aborting surfaces the + // original validation error rather than a truncated list. + if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded(method); + pages += 1; const effectiveMeta = this.mergeMeta(metadata); const params = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), @@ -3569,7 +3584,14 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.excludesInvalidXMcpHeaderTools()) { const seenCursors = new Set<string>(); let cursor: string | undefined; + let pages = 0; do { + // Same bound as `salvageList`, for the same reason: this walk is also + // outside the SDK aggregate's `listMaxPages` cap. Throwing (rather than + // committing a partial set) keeps the caller's "the excluded list may + // be incomplete" warning the honest description of what happened. + if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded("tools/list"); + pages += 1; const page = await this.listToolsForScan(cursor, metadata); malformed.push( ...page.malformed.map((entry) => ({ @@ -3641,53 +3663,90 @@ export class InspectorClient extends InspectorClientEventTarget { // client refused the result. const rejectedResponseId = this.lastAnsweredRequestByMethod.get("tools/list"); - const effectiveMeta = this.mergeMeta(metadata); - const params = { - ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - // See `salvageList`: "" is a valid cursor. - ...(cursor !== undefined && { cursor }), - }; - const pageSchema = lenientListPageSchema(ListToolsResultSchema, "tools"); - const page = this.isModernEra() - ? await this.rawWireRequest( - "tools/list", - this.withModernEnvelope(params), - pageSchema, - ) - : await this.client!.request( - { method: "tools/list", params }, - pageSchema, - this.getRequestOptions(metadata?.progressToken), + try { + const effectiveMeta = this.mergeMeta(metadata); + const params = { + ...(effectiveMeta ? { _meta: effectiveMeta } : {}), + // See `salvageList`: "" is a valid cursor. + ...(cursor !== undefined && { cursor }), + }; + const pageSchema = lenientListPageSchema( + ListToolsResultSchema, + "tools", + ); + const page = this.isModernEra() + ? await this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + pageSchema, + ) + : await this.client!.request( + { method: "tools/list", params }, + pageSchema, + this.getRequestOptions(metadata?.progressToken), + ); + // Same era-envelope check as `salvageList` — the raw-wire path bypasses + // the codec that would otherwise enforce it. + if ( + this.isModernEra() && + !ModernResultEnvelopeSchema.safeParse(page).success + ) { + throw err; + } + const items = rawItemsOf(page, "tools"); + // Not a list at all — a top-level violation the per-item pass can't + // explain, so the strict error stands (mirrors `salvageList`). + if (items === undefined) throw err; + const { valid, malformed } = salvageListItems({ + method: "tools/list", + items, + schema: toolItemSchemaForEra(this.isModernEra()), + }); + if (rejectedResponseId !== undefined && malformed.length > 0) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: summarizeMalformed(malformed), + }); + } + const nextCursor = nextCursorOf(page); + return { + tools: valid, + ...(nextCursor !== undefined && { nextCursor }), + malformed, + }; + } catch (fallbackErr) { + // The fallback could not explain the rejection — the re-fetch itself + // failed, or the page carried a violation that is not per-item (a bad + // modern envelope, a `tools` that is not a list). + // + // `salvageList` can simply rethrow here, because ITS caller + // (`ManagedListState.refresh`) marks the Protocol entry from the error + // it receives. This walk has no such backstop: `listAllTools` catches + // and LOGS a failing scan so it can never fail the tools list, so an + // unmarked rethrow leaves the response the client demonstrably refused + // (`isSalvageableRejection` above proves it was an `InvalidResult`) + // rendering as a clean exchange — the exact lie #1953 removes and the + // one this method's own capture above exists to prevent. So mark it + // here, with the ORIGINAL decode error as the reason: the fallback's + // own failure is a different event, and the response being marked is + // the one `err` was raised for. + if (rejectedResponseId !== undefined) { + this.dispatchTypedEvent("responseRejected", { + id: rejectedResponseId, + reason: err instanceof Error ? err.message : String(err), + }); + } + if (fallbackErr !== err) { + this.logger.warn( + { err: fallbackErr }, + "Excluded-tools salvage re-fetch failed; surfacing the original list error", ); - // Same era-envelope check as `salvageList` — the raw-wire path bypasses - // the codec that would otherwise enforce it. - if ( - this.isModernEra() && - !ModernResultEnvelopeSchema.safeParse(page).success - ) { + } + // Restore the correlation the re-fetch moved, so a later mark for this + // method does not land on a lenient page that succeeded. + this.restoreResponseCorrelation("tools/list", rejectedResponseId); throw err; } - const items = rawItemsOf(page, "tools"); - // Not a list at all — a top-level violation the per-item pass can't - // explain, so the strict error stands (mirrors `salvageList`). - if (items === undefined) throw err; - const { valid, malformed } = salvageListItems({ - method: "tools/list", - items, - schema: toolItemSchemaForEra(this.isModernEra()), - }); - if (rejectedResponseId !== undefined && malformed.length > 0) { - this.dispatchTypedEvent("responseRejected", { - id: rejectedResponseId, - reason: summarizeMalformed(malformed), - }); - } - const nextCursor = nextCursorOf(page); - return { - tools: valid, - ...(nextCursor !== undefined && { nextCursor }), - malformed, - }; } } diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 7c5ab1e4..f043cf60 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -46,6 +46,41 @@ export interface MalformedListItem { reason: string; } +/** + * Hard cap on the pages a salvage / scan re-walk will fetch. + * + * The SDK's auto-aggregating `list*()` is capped by + * `ClientOptions.listMaxPages` and THROWS when it is hit, so a partial + * aggregate is never presented as a complete one. The fallback walks in this + * module go around that aggregate, so without their own bound a server whose + * `nextCursor` never converges — but never repeats, so the non-converging guard + * never fires — would keep the walk issuing requests and accumulating entries + * forever. That is reachable from any server the user points the Inspector at, + * which is the whole population here. + * + * The value is passed to the SDK client as `listMaxPages` (see + * `InspectorClient`'s constructor) rather than merely matching its default, so + * the strict path and the fallback are bounded by the SAME number by + * construction and cannot drift if the SDK changes its default. + */ +export const LIST_MAX_PAGES = 64; + +/** + * The error a walk raises when it hits {@link LIST_MAX_PAGES}. + * + * Both callers turn this into "the strict error stands": hitting the cap means + * there are more entries we did not fetch, so returning what we have would + * present a TRUNCATED list as a complete one — the same silent-truncation + * failure {@link rawItemsOf} refuses for a non-array page. That is why this + * aborts rather than `break`ing like the repeated-cursor guard, which stops on + * a page whose entries were all seen already and so loses nothing. + */ +export function listPaginationExceeded(method: string): Error { + return new Error( + `Salvage walk for ${method} exceeded ${LIST_MAX_PAGES} pages without the server's pagination converging`, + ); +} + /** * Whether a failed fetch is the CLIENT refusing a response it received, rather * than the request never producing one. From 2fdbd1905f6d6619bfd5ad805fcf2a88c62d2537 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:44:09 -0400 Subject: [PATCH 145/941] fix: make the collapse and null-admission fail closed on unevaluated schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 8 opened the same conjunctive-semantics seam a third time, so this stops patching cases and inverts the defaults instead. Both guards now fail CLOSED: anything this module cannot evaluate sends the field to the JSON editor rather than to a widget built from a partial reading. 1. The wrapper-conflict check enumerated the *validation* keywords and declined when two of them disagreed — which meant any keyword nobody listed (`oneOf`, `allOf`, `not`, `$ref`, a future vocabulary) was silently treated as safe to drop. That whitelist fails open, and open means rendering a widget for a constraint that is not the schema's. It is replaced by an allowlist of *annotation* keywords: the collapse declines whenever the wrapper carries anything beyond annotations and the union itself. Stricter than the old equality test — a wrapper restating its branch's `type` now declines too — which costs a dropdown on a rare redundant shape and buys a rule with no gap in it. 2. `admitsNull` returned true for shapes it had not actually evaluated: `not: { type: "null" }` rules null out, `allOf` can add a member that does, `oneOf` requires *exactly one* branch to match (so a null branch does not by itself mean null validates), and a branch like `{ type: "null", const: "x" }` is unsatisfiable. All now answer false. Under-claiming nullability costs a clear button; over-claiming lets the form emit a value the schema rejects, so the asymmetry decides it. Note the third finding — draft validity never reaching the submit controls — is NOT fixed here, and it is worse than I described last round: `ToolDetailPanel` gates Execute on `isExecuting` alone and never calls `hasMissingRequiredFields`, so my earlier claim that required fields were already covered was wrong for the Tools tab. Corrected on #2020, which is where the fix belongs. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 77 +++++++--- core/json/nullableUnion.ts | 133 ++++++++++-------- 2 files changed, 132 insertions(+), 78 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index a2127f2b..3a59953a 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -418,21 +418,24 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); - // Identical values are no conflict, so the ordinary redundant-type shape - // still collapses rather than being punished for being explicit. - it("collapses when wrapper and branch agree on a shared keyword", () => { - expect( - normalizeNullableUnion({ - type: "string", - anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }], - }), - ).toEqual({ + // Deliberately stricter than value-equality: a wrapper restating its branch's + // `type` also declines. That costs a dropdown on a rare redundant shape and + // buys a rule with no gap, which is the better trade for a module that cannot + // evaluate JSON Schema. + it("declines even when wrapper and branch agree on a shared keyword", () => { + const schema = { type: "string", - enum: ["a"], - anyOf: undefined, - // The explicit non-null `type` rules null out, conjunctively. - nullable: false, - }); + anyOf: [{ type: "string", enum: ["a"] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when the wrapper carries an applicator the module cannot read", () => { + const schema = { + allOf: [{ minLength: 2 }], + anyOf: [{ type: "string" }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); }); it("treats wrapper metadata as safe to keep alongside branch constraints", () => { @@ -484,13 +487,51 @@ describe("admitsNull", () => { expect(admitsNull(wide)).toBe(true); }); - it("recognizes a null branch in a oneOf, and a nested type array", () => { - expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe( - true, - ); + it("recognizes a null branch declared as a nested type array", () => { expect(admitsNull({ anyOf: [{ type: ["string", "null"] }] })).toBe(true); }); + // Applicators this module does not evaluate. `oneOf` requires *exactly one* + // branch to match, so a null branch does not by itself mean null validates; + // `not` and `allOf` can rule it out outright. Under-claiming costs a clear + // button, over-claiming lets the form emit a value the schema rejects. + it("declines to claim nullability for applicators it cannot evaluate", () => { + expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe( + false, + ); + expect( + admitsNull({ not: { type: "null" }, anyOf: [{ type: "null" }] }), + ).toBe(false); + expect( + admitsNull({ allOf: [{ type: "string" }], anyOf: [{ type: "null" }] }), + ).toBe(false); + }); + + // A branch can name null and still admit nothing. + it("ignores an unsatisfiable null branch", () => { + expect( + admitsNull({ anyOf: [{ type: "string" }, { type: "null", const: "x" }] }), + ).toBe(false); + expect( + admitsNull({ + anyOf: [{ type: "string" }, { type: "null", enum: ["x"] }], + }), + ).toBe(false); + expect( + admitsNull({ + anyOf: [{ type: "string" }, { type: "null", not: { type: "null" } }], + }), + ).toBe(false); + }); + + it("still accepts a plain null branch alongside an unsatisfiable one", () => { + expect( + admitsNull({ + anyOf: [{ type: "null", const: "x" }, { type: "null" }], + }), + ).toBe(true); + }); + it("is false for a schema that does not permit null", () => { expect(admitsNull({ type: "string" })).toBe(false); expect(admitsNull({ type: ["string", "number"] })).toBe(false); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index c24fec92..9f79af3d 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -45,6 +45,8 @@ export interface NullableUnionSchema { // Read only by `admitsNull`, which considers encodings the collapse itself // declines to flatten, and the sibling constraints that can rule null out. oneOf?: readonly unknown[]; + allOf?: readonly unknown[]; + not?: unknown; nullable?: boolean; const?: unknown; } @@ -127,69 +129,63 @@ function nullExcludedBySiblings(schema: NullableUnionSchema): boolean { } /** - * Keywords that *constrain what values are valid*, as opposed to describing the - * field (`title`, `description`, `default`, `enumNames`). + * Keywords that only *describe* a field. Everything else constrains what values + * are valid. * - * Only these can conflict in a way that matters: a wrapper and its branch each - * carrying one means the value must satisfy **both**, which the hoist cannot - * express. Metadata is safe to merge because nothing validates against it. + * This is an **allowlist on purpose**, and the direction matters. The previous + * version enumerated the *validation* keywords and declined on a conflict among + * them, which meant any keyword nobody thought of — `oneOf`, `allOf`, `not`, + * `$ref`, a future vocabulary — was silently treated as safe to drop. A missing + * entry there fails open, and open means rendering a widget for a constraint + * that is not the schema's. Listing the harmless keys instead fails closed: an + * unrecognized keyword sends the field to the JSON editor, which is the correct + * answer for a schema this module does not understand. */ -const VALIDATION_KEYWORDS = [ - "type", - "enum", - "const", - "items", - "properties", - "required", - "additionalProperties", - "minimum", - "maximum", - "exclusiveMinimum", - "exclusiveMaximum", - "multipleOf", - "minLength", - "maxLength", - "pattern", - "format", - "minItems", - "maxItems", - "uniqueItems", -] as const; +const ANNOTATION_KEYWORDS = new Set([ + "title", + "description", + "default", + "enumNames", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$comment", + "$schema", + "$id", +]); /** - * Whether hoisting this branch would *drop* one of the wrapper's constraints. + * Whether hoisting a branch would *drop* something the wrapper constrains. * * The hoist is a spread, so a branch key replaces the wrapper's — but JSON * Schema applies sibling keywords **conjunctively**, and a replacement is not a * conjunction. A wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means * `"a"` (both must hold), yet the spread yields `["a", "b"]` and the rendered * dropdown would offer — and submit — a `"b"` the schema rejects. `type`, - * bounds, and the object keywords fail the same way. + * bounds, the object keywords, and the applicators (`oneOf`, `allOf`, `not`) + * all fail the same way. * - * Rather than implement intersection for every keyword (a much larger surface, - * and one where a subtly wrong intersection is worse than none), the collapse - * **declines** when wrapper and branch disagree, and the field renders through - * the JSON editor with its full schema intact. Identical values are not a - * conflict, so the common `{ type: "string", anyOf: [{ type: "string", … }] }` - * still collapses. + * Rather than intersect them — a much larger surface, and one where a subtly + * wrong intersection is worse than none because it renders a *plausible* widget + * for the wrong constraint — the collapse **declines** whenever the wrapper + * carries anything beyond annotations and the union itself. The field then + * renders through the JSON editor with its full schema intact. * - * Comparison is by canonical JSON, so two structurally equal objects written - * with different key order read as a conflict. That is conservative in the safe - * direction: the field falls back rather than mis-renders. + * This is stricter than comparing values for equality: a wrapper that merely + * restates its branch's `type` now also declines. That costs a dropdown on a + * rare redundant shape and buys a rule with no gap in it, which is the better + * trade for a module that cannot evaluate JSON Schema. */ -function branchDropsWrapperConstraint( - schema: NullableUnionSchema, - branch: Record<string, unknown>, -): boolean { - const wrapper = schema as Record<string, unknown>; - return VALIDATION_KEYWORDS.some((keyword) => { - const wrapperValue = wrapper[keyword]; - const branchValue = branch[keyword]; - if (wrapperValue === undefined || branchValue === undefined) { - return false; - } - return JSON.stringify(wrapperValue) !== JSON.stringify(branchValue); - }); +function wrapperCarriesConstraints(schema: NullableUnionSchema): boolean { + return Object.keys(schema).some( + (key) => key !== "anyOf" && !ANNOTATION_KEYWORDS.has(key), + ); +} + +/** Whether a schema's `type` names `"null"` at all. */ +function typeNamesNull(type: unknown): boolean { + return Array.isArray(type) ? type.includes("null") : type === "null"; } /** @@ -313,6 +309,19 @@ export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { return false; } + // Applicators this module does not evaluate. `not: { type: "null" }` rules + // null out; `allOf` can add a member that does; and `oneOf` requires + // **exactly one** branch to match, so a null branch does not by itself mean + // `null` validates — two matching branches make it fail. Rather than answer + // these half-correctly, say no: under-claiming nullability costs a clear + // button, while over-claiming lets the form emit a value the schema rejects. + if ( + schema.not !== undefined || + schema.allOf !== undefined || + schema.oneOf !== undefined + ) { + return false; + } if (schema.nullable === true) { return true; } @@ -322,21 +331,25 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // as the union. Falling through to the branch scan here would let the union // override a constraint that outranks it. if (schema.type !== undefined) { - return Array.isArray(schema.type) - ? schema.type.includes("null") - : schema.type === "null"; + return typeNamesNull(schema.type); } - return [schema.anyOf, schema.oneOf].some((branches) => - branches?.some((entry) => { + return ( + schema.anyOf?.some((entry) => { const branch = toBranch(entry); - if (branch === null) { + if (branch === null || !typeNamesNull(branch.type)) { return false; } + // A branch that names null can still admit nothing: `{ type: "null", + // const: "x" }` is unsatisfiable, and its own applicators are as opaque + // here as the wrapper's. + const branchSchema = branch as NullableUnionSchema; return ( - branch.type === "null" || - (Array.isArray(branch.type) && branch.type.includes("null")) + !nullExcludedBySiblings(branchSchema) && + branchSchema.not === undefined && + branchSchema.allOf === undefined && + branchSchema.oneOf === undefined ); - }), + }) ?? false ); } @@ -375,7 +388,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( // Hoisting would silently drop a wrapper constraint the branch also // carries, so decline instead; see `branchDropsWrapperConstraint`. - if (nullBranch && branch && !branchDropsWrapperConstraint(schema, branch)) { + if (nullBranch && branch && !wrapperCarriesConstraints(schema)) { // A branch may carry an `enum` and no `type`; JSON Schema allows that, and // an all-string enum is unambiguously a string field. See isStringEnum for // why a non-string enum deliberately does not get the same treatment. From dbda70795bbdc66f4ec6064ccaec8350fd5642d3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:52:50 -0400 Subject: [PATCH 146/941] fix: make the salvage item contract match each era in both directions (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 7. The era contract was derived from the neutral ToolSchema plus one override, which diverged from the real wire schemas both ways. Measured against SDK 2.0.0 on a real connection: - modern accepts `inputSchema.properties: 5` and `required: [1]`; ours rejected them, so a malformed sibling made salvage DROP a conforming tool and report it as malformed — a false accusation from the screen whose job is to say which entry is wrong. - legacy rejects `outputSchema.properties: 5` / `required: [1]`; ours accepted them, readmitting entries the strict path refused. - modern rejects a non-string `inputSchema.$schema`, legacy accepts it; ours accepted on both. Each era is now expressed as its own wire schema, bridged to Tool by `eraToolItemSchema`. The bridge exists because both eras accept values the exported Tool type cannot describe (the SDK returns them typed as Tool anyway), so the wire schema's output type is irrelevant and z.custom<Tool> supplies the caller's type — which is what avoids an `as unknown as` here. Forwarded issues keep their path so the report still names the field. The full measured matrix is pinned table-driven in listSalvage-era.test.ts: each row asserts the SDK's verdict AND that our contract agrees, so a divergence that closes or widens fails loudly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- .../integration/mcp/listSalvage-era.test.ts | 131 ++++++++++++++- core/mcp/listSalvage.ts | 155 ++++++++++++++---- 2 files changed, 256 insertions(+), 30 deletions(-) diff --git a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts index 723b47da..e90af022 100644 --- a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts +++ b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts @@ -9,7 +9,10 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; import { ToolSchema } from "@modelcontextprotocol/core"; -import { toolItemSchemaForEra } from "@inspector/core/mcp/listSalvage.js"; +import { + describeIssues, + toolItemSchemaForEra, +} from "@inspector/core/mcp/listSalvage.js"; /** * The era item contract the salvage fallback validates tools against (#1909). @@ -179,6 +182,21 @@ describe("salvage era item contract (#1909)", () => { expect(neutral.success && neutral.data).toHaveProperty("execution"); }); + it("names the offending field, not just 'invalid tool'", () => { + // The era contract validates through a wire schema and forwards its issues, + // so the PATH has to survive that hop — it is the whole diagnostic value of + // the report, and a collapsed "Invalid input" would still pass every + // accept/reject assertion above. + const parsed = toolItemSchemaForEra(false).safeParse({ + name: "t", + inputSchema: { type: "array" }, + }); + expect(parsed.success).toBe(false); + expect(parsed.success || describeIssues(parsed.error)).toMatch( + /^inputSchema\.type: .*object/, + ); + }); + it("the era contract matches the strict path on both divergences", () => { // Legacy: rejected, exactly as the strict parse above rejected it. expect( @@ -190,6 +208,117 @@ describe("salvage era item contract (#1909)", () => { expect(modern.success && modern.data).not.toHaveProperty("execution"); }); + /** + * What the STRICT path did with a tool, decided without consulting our own + * contract: the SDK accepted it only if the tool came back and nothing was + * reported malformed. (If the SDK rejects and our contract accepts, salvage + * finds nothing per-item wrong and rethrows — also a "reject" observation.) + */ + async function strictVerdict( + tool: unknown, + era: "legacy" | "modern", + ): Promise<"accept" | "reject"> { + const server = await startToolsServer([tool], era === "modern"); + stop = server.stop; + const connected = await connect(server.url, era); + try { + const { tools } = await connected.listAllTools(); + return tools.length === 1 && + connected.getMalformedListItems().length === 0 + ? "accept" + : "reject"; + } catch { + return "reject"; + } + } + + const withInput = (inputSchema: unknown) => ({ name: "t", inputSchema }); + const withOutput = (outputSchema: unknown) => ({ + name: "t", + inputSchema: { type: "object" }, + outputSchema, + }); + + /** + * Every measured divergence between the two eras and the neutral schema. + * + * Each row is checked twice: that the SDK still behaves this way, and that + * our replicated contract agrees with it. The two directions both matter — + * an `accept` we reject means salvage would drop a tool the strict path kept + * and report a conforming server as malformed; a `reject` we accept means + * salvage readmits an entry the era refused. + */ + const ERA_CASES: { + label: string; + tool: unknown; + legacy: "accept" | "reject"; + modern: "accept" | "reject"; + }[] = [ + { + label: "inputSchema.type: array", + tool: withInput({ type: "array" }), + legacy: "reject", + modern: "reject", + }, + { + label: "inputSchema.properties: 5", + tool: withInput({ type: "object", properties: 5 }), + legacy: "reject", + modern: "accept", + }, + { + label: "inputSchema.required: [1]", + tool: withInput({ type: "object", required: [1] }), + legacy: "reject", + modern: "accept", + }, + { + label: "inputSchema.$schema: 5", + tool: withInput({ type: "object", $schema: 5 }), + legacy: "accept", + modern: "reject", + }, + { + label: "outputSchema.type: array", + tool: withOutput({ type: "array" }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.properties: 5", + tool: withOutput({ type: "object", properties: 5 }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.required: [1]", + tool: withOutput({ type: "object", required: [1] }), + legacy: "reject", + modern: "accept", + }, + { + label: "outputSchema.$schema: 5", + tool: withOutput({ type: "object", $schema: 5 }), + legacy: "accept", + modern: "reject", + }, + ]; + + for (const era of ["legacy", "modern"] as const) { + for (const { label, tool, ...expected } of ERA_CASES) { + it(`${era}: ${label} — contract agrees with the strict path`, async () => { + const observed = await strictVerdict(tool, era); + // Pins the SDK: if this fails, the era rule changed and the replicated + // contract below needs revisiting rather than the test relaxing. + expect(observed).toBe(expected[era]); + // Pins our agreement with it, in whichever direction it differs. + expect( + toolItemSchemaForEra(era === "modern").safeParse(tool).success, + ).toBe(expected[era] === "accept"); + }); + } + } + it("keeps only era-valid tools when salvaging beside a malformed one", async () => { // The end-to-end shape of the defect: a malformed entry trips the // fallback, and the array-output tool must NOT ride along on the strength diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index f043cf60..1f2109c3 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -229,11 +229,23 @@ export function describeIssues(error: z.ZodError): string { /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ if (!issue) return "did not match the expected shape"; const path = issue.path.join("."); - const detail = expectedDetail(issue); - const message = detail ? `${issue.message} (${detail})` : issue.message; + const message = describeIssue(issue); return path.length > 0 ? `${path}: ${message}` : message; } +/** + * One issue's message, with the expected type folded in when it adds something. + * + * Split out from {@link describeIssues} because {@link eraToolItemSchema} has to + * apply it EARLY: forwarding another schema's issues re-emits them as `custom` + * ones, which carry a message and a path but no `expected` field, so the detail + * has to be baked into the message while the original issue is still in hand. + */ +function describeIssue(issue: z.core.$ZodIssue): string { + const detail = expectedDetail(issue); + return detail ? `${issue.message} (${detail})` : issue.message; +} + /** * `expected object` — appended because the issue's own message cannot be relied * on to carry it. zod's browser build reports a bare "Invalid input" where Node @@ -305,36 +317,121 @@ export function summarizeMalformed(malformed: MalformedListItem[]): string { /** * The item contract for `tools/list` on a given era. * - * The public `ToolSchema` is era-NEUTRAL, and the negotiated era's wire schema - * is stricter in ways the SDK does not export. Validating candidates against - * the neutral schema alone would let the fallback keep an entry the strict path - * refused — an entry the user would then see, and could call, on a connection - * whose era says it isn't valid. The two known divergences (verified against - * SDK 2.0.0 by `listSalvage-era.test.ts`, which fails if either changes): + * The public `ToolSchema` is era-NEUTRAL, and each negotiated era's wire schema + * differs from it — in BOTH directions, which is the thing to keep in mind + * here. The obvious failure is a fallback that is more permissive than the + * strict path, readmitting an entry the era refused. The equally real one is a + * fallback that is more STRICT: it drops a tool the strict path would have + * kept, and this module then reports it as malformed — accusing a conforming + * server of an error it did not make, on a screen whose whole job is to say + * which entry is wrong. * - * - **legacy (2025)** requires `outputSchema.type === "object"`; the neutral - * schema accepts any object, so a tool with `outputSchema: { type: "array" }` - * would survive salvage while the strict parse rejects the whole page. - * - **modern (2026)** deletes `execution`, and the strict path STRIPS it. Left - * in place, a salvaged tool would carry a field the era doesn't have, and the - * UI would render capability the connection can't honor. + * Every rule below was measured against SDK 2.0.0 on a real connection rather + * than read off the (unexported) era schemas, and each is pinned by + * `listSalvage-era.test.ts` so a divergence that closes or widens fails loudly + * instead of rotting: + * + * | case | 2025 (legacy) | 2026 (modern) | + * | --------------------------- | ------------- | ------------- | + * | `inputSchema.type: "array"` | reject | reject | + * | `inputSchema.properties: 5` | reject | **accept** | + * | `inputSchema.required: [1]` | reject | **accept** | + * | `inputSchema.$schema: 5` | **accept** | reject | + * | `outputSchema.type: "array"`| reject | **accept** | + * | `outputSchema.properties: 5`| reject | **accept** | + * | `outputSchema.required: [1]`| reject | **accept** | + * | `outputSchema.$schema: 5` | **accept** | reject | + * | `execution` | kept | **stripped** | + * + * Which reduces to two overrides on the neutral schema, one per era, plus the + * modern `execution` strip — see {@link LegacyToolWireSchema} and + * {@link ModernToolWireSchema}. * * Replicating rules that live in the SDK's internals is a real cost, taken - * deliberately: the alternative is a fallback that is quietly more permissive - * than the path it stands in for. The tests pin each rule to observed SDK - * behavior, so a divergence that closes (or widens) fails loudly rather than - * rotting. + * deliberately: the alternative is a fallback that quietly disagrees with the + * path it stands in for, in whichever direction the neutral schema happens to + * differ. */ export function toolItemSchemaForEra(isModern: boolean): z.ZodType<Tool> { - if (isModern) { - // `execution` is not part of the 2026 wire shape; drop it as the codec does. - return ToolSchema.transform((tool) => { - if (tool.execution === undefined) return tool; - const { execution: _execution, ...rest } = tool; - return rest; - }); - } - return ToolSchema.extend({ - outputSchema: z.looseObject({ type: z.literal("object") }).optional(), - }); + return isModern + ? eraToolItemSchema(ModernToolWireSchema, stripExecution) + : eraToolItemSchema(LegacyToolWireSchema, (tool) => tool); +} + +/** + * The 2025 wire shape for a tool. + * + * The only divergence from the neutral schema is `outputSchema`, which the + * legacy codec validates with the SAME shape it uses for `inputSchema` — a + * required `type: "object"`, `properties` as a JSON record, `required` as + * strings — where the neutral schema checks only `$schema`. Reusing + * `ToolSchema.shape.inputSchema` states that relationship instead of + * re-deriving it, so the two cannot drift apart by hand. + */ +const LegacyToolWireSchema = ToolSchema.extend({ + outputSchema: ToolSchema.shape.inputSchema.optional(), +}); + +/** + * The 2026-07-28 wire shape for a tool. + * + * Its `inputSchema` is the mirror image of the legacy divergence: the modern + * codec constrains only `type` and `$schema` and leaves the remaining JSON + * Schema keywords open, where the neutral schema additionally validates + * `properties` and `required`. `outputSchema` needs no override — the neutral + * `$schema`-only check is already what the modern codec applies. + */ +const ModernToolWireSchema = ToolSchema.extend({ + inputSchema: z.looseObject({ + type: z.literal("object"), + $schema: z.string().optional(), + }), +}); + +/** `execution` is not part of the 2026 wire shape; drop it as the codec does. */ +function stripExecution(tool: Tool): Tool { + if (tool.execution === undefined) return tool; + const { execution: _execution, ...rest } = tool; + return rest; +} + +/** + * Bridge a per-era WIRE schema to the `Tool` the rest of the client works with. + * + * The two are deliberately separate, because on BOTH eras the wire schema + * accepts values the exported `Tool` type cannot describe — `properties: 5` is + * a valid modern `inputSchema` keyword set, and the SDK hands exactly that back + * typed as a `Tool`. So the era rules are expressed as an ordinary schema whose + * output type is irrelevant (it is only ever consulted for a verdict), and + * `z.custom<Tool>` supplies the type the caller needs. That is what avoids the + * `as unknown as` this would otherwise require — and it is the same assertion + * the SDK itself makes, in the same place. + * + * The wire schema's issues are copied across one by one, each keeping its PATH, + * rather than collapsed into a single "invalid tool" — so `describeIssues` can + * still name the offending field (`inputSchema.type`) instead of reporting a + * bare "Invalid input", which is the whole point of the report this module + * produces. They are re-emitted as `custom` issues (the only code that can + * carry an arbitrary forwarded message), which is why the expected-type detail + * is folded into the message here by {@link describeIssue} rather than left for + * `describeIssues` to recover from a field the new issue will not have. + */ +function eraToolItemSchema( + wire: z.ZodType, + normalize: (tool: Tool) => Tool, +): z.ZodType<Tool> { + return z + .custom<Tool>() + .superRefine((value, ctx) => { + const parsed = wire.safeParse(value); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + ctx.addIssue({ + code: "custom", + path: issue.path, + message: describeIssue(issue), + }); + } + }) + .transform(normalize); } From 274a328abef59744878e15b3d04aad7c3b5bc412 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 19:58:07 -0400 Subject: [PATCH 147/941] fix: apply the fail-closed rule uniformly, and correct the admitsNull docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 9: four findings, all of them places where round 8's fail-closed inversion was the right idea applied incompletely. The rule was only enforced at the positions I happened to be editing; it now holds everywhere a schema is inspected. 1. `admitsNull` returned early on an explicit `type`, ignoring a sibling `anyOf` — but the two are conjunctive, so `{ type: ["string", "null"], anyOf: [{ type: "string" }] }` admits no null at all. A sibling union now withholds the claim. 2. A null-typed branch could be unsatisfiable via its own nested `anyOf` (`{ type: "null", anyOf: [{ type: "string" }] }`). The branch check tested `not`/`allOf`/`oneOf` but not `anyOf`; it now treats a nested union as equally opaque. 3. The `type: [T, "null"]` collapse path had no applicator guard at all — `collapsed` clears `anyOf` unconditionally and the renderers ignore the rest, so `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` was widened into an unconstrained string field. That path now declines the same way the `anyOf` path does. 4. The `admitsNull` docstring still advertised `oneOf` support that round 8 deliberately removed. Rewritten to state what is recognized, what is declined and why, and the asymmetry that settles the direction. The three code fixes share one helper (`hasOpaqueApplicator`) rather than repeating the condition, so the next position that needs it cannot drift. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 49 ++++++++++++-- core/json/nullableUnion.ts | 66 +++++++++++++++---- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 3a59953a..525dae29 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -357,6 +357,26 @@ describe("normalizeNullableUnion", () => { }); }); + // The type-array path keeps the schema's own keywords, but `collapsed` clears + // `anyOf` and the renderers ignore the other applicators — so a compound + // schema would be widened into an unconstrained field rather than collapsed. + it("declines a type-array nullable that also carries a sibling anyOf", () => { + const schema = { + type: ["string", "null"], + anyOf: [{ const: "a" }], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines a type-array nullable that carries an opaque applicator", () => { + const withNot = { type: ["string", "null"], not: { const: "a" } }; + expect(normalizeNullableUnion(withNot)).toBe(withNot); + const withOneOf = { type: ["string", "null"], oneOf: [{ const: "a" }] }; + expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf); + const withAllOf = { type: ["string", "null"], allOf: [{ minLength: 2 }] }; + expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf); + }); + it("normalizes type array array|null", () => { expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({ type: "array", @@ -524,6 +544,17 @@ describe("admitsNull", () => { ).toBe(false); }); + it("ignores a null branch made unsatisfiable by its own nested anyOf", () => { + expect( + admitsNull({ + anyOf: [ + { type: "string" }, + { type: "null", anyOf: [{ type: "string" }] }, + ], + }), + ).toBe(false); + }); + it("still accepts a plain null branch alongside an unsatisfiable one", () => { expect( admitsNull({ @@ -588,14 +619,20 @@ describe("admitsNull", () => { }); it("still honors an explicit type that does admit null", () => { + expect(admitsNull({ type: ["string", "null"] })).toBe(true); + expect(admitsNull({ type: "null" })).toBe(true); + }); + + // A sibling union is conjunctive with the `type`, so it can reject null even + // when the type list names it. It is not evaluated here, so its mere presence + // withholds the claim. + it("withholds the claim when a sibling anyOf sits beside an explicit type", () => { expect( - admitsNull({ - type: ["string", "null"], - anyOf: [{ type: "string" }, { type: "null" }], - }), - ).toBe(true); + admitsNull({ type: ["string", "null"], anyOf: [{ type: "string" }] }), + ).toBe(false); + // Unsatisfiable: names null, admits nothing. expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe( - true, + false, ); }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 9f79af3d..2e4db037 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -188,6 +188,23 @@ function typeNamesNull(type: unknown): boolean { return Array.isArray(type) ? type.includes("null") : type === "null"; } +/** + * Applicators this module does not evaluate, and which are **conjunctive** with + * everything beside them — so any of them can rule `null` out, or constrain a + * value in a way no widget here would honor. + * + * `anyOf` is excluded because it is the keyword being *recognized*: a caller + * that has already identified the nullable-union shape checks it directly. Every + * other position treats a stray `anyOf` as opaque too. + */ +function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { + return ( + schema.not !== undefined || + schema.allOf !== undefined || + schema.oneOf !== undefined + ); +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -301,9 +318,24 @@ function collapsed<T extends NullableUnionSchema>( * other would make a form reject a value its own schema accepts, which is why * these are two functions rather than a `.nullable` flag read off the collapse. * - * Recognizes every encoding the collapse does, plus the ones it declines: - * `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"` - * branch anywhere in an `anyOf` or `oneOf` of any size. + * **Answers `false` for anything it cannot fully evaluate**, which is a larger + * set than it may look. Recognized: `nullable: true`, `type: "null"`, + * `type: [..., "null"]`, and a `"null"` branch in an `anyOf` of any size. + * Deliberately *not* recognized, despite naming null: + * + * - **`oneOf`** — it requires *exactly one* branch to match, so a `null` branch + * does not mean `null` validates; two matching branches make it fail. + * - **`not` / `allOf`**, at this level or on the branch, which can rule null out. + * - **A sibling `anyOf` beside an explicit `type`**, since the two are + * conjunctive and the union is not evaluated here. + * - **A branch that names null but is unsatisfiable**, whether by its own + * `enum`/`const` or its own nested applicators. + * + * The asymmetry is what settles the direction: under-claiming costs a clear + * button and treats a valid `null` as missing, while over-claiming lets the + * form emit a value the schema rejects. So callers get a firm "no" rather than + * an optimistic guess, and a schema this module cannot read renders through the + * JSON editor with its constraints intact. */ export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { @@ -315,11 +347,7 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // `null` validates — two matching branches make it fail. Rather than answer // these half-correctly, say no: under-claiming nullability costs a clear // button, while over-claiming lets the form emit a value the schema rejects. - if ( - schema.not !== undefined || - schema.allOf !== undefined || - schema.oneOf !== undefined - ) { + if (hasOpaqueApplicator(schema)) { return false; } if (schema.nullable === true) { @@ -331,6 +359,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // as the union. Falling through to the branch scan here would let the union // override a constraint that outranks it. if (schema.type !== undefined) { + // A sibling `anyOf` is conjunctive with the `type`, so it can reject null + // even when the type list names it — `{ type: ["string", "null"], anyOf: + // [{ type: "string" }] }` admits no null at all. Not evaluated here, so + // its mere presence withholds the claim. + if (schema.anyOf !== undefined) { + return false; + } return typeNamesNull(schema.type); } return ( @@ -345,9 +380,10 @@ export function admitsNull(schema: NullableUnionSchema): boolean { const branchSchema = branch as NullableUnionSchema; return ( !nullExcludedBySiblings(branchSchema) && - branchSchema.not === undefined && - branchSchema.allOf === undefined && - branchSchema.oneOf === undefined + !hasOpaqueApplicator(branchSchema) && + // A nested union inside the branch is as opaque as the others: `{ type: + // "null", anyOf: [{ type: "string" }] }` names null and admits nothing. + branchSchema.anyOf === undefined ); }) ?? false ); @@ -408,7 +444,13 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( if ( Array.isArray(schema.type) && schema.type.length === 2 && - schema.type.includes("null") + schema.type.includes("null") && + // This path keeps the schema's own keywords, but `collapsed` clears `anyOf` + // and the renderers ignore the other applicators — so a compound schema + // like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be + // widened into an unconstrained string field. Leave it for the JSON editor. + schema.anyOf === undefined && + !hasOpaqueApplicator(schema) ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { From 7db807b0de1d2cf0e340455c77bda13b5b48e6cb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 20:08:03 -0400 Subject: [PATCH 148/941] fix: return the era-parsed tool, and hide aggregate reports in paginated mode (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 8. The era contract validated with the wire schema but returned the raw input, so the wire schema's normalization was discarded — both era schemas are z.object, which strips unknown top-level fields as the strict codec does, so salvaged tools could carry fields the strict path drops. The parsed output is now what gets normalized and returned. This also simplifies the bridge: issues come straight from the wire parse with their paths intact, so the custom-issue forwarding (and its early describeIssue application) is gone. The malformed-entry report is written by the aggregate walk's salvage, but in paginated mode the tools/prompts/resources panels render the paged stores, which neither write nor clear it — leaving a stale warning above a freshly fetched page it does not describe, with indices into an aggregate the user is no longer looking at. Those reports are now suppressed in paginated mode; resource templates keep theirs, having no paged store. Suppressing rather than adding per-page salvage: pointing at the wrong entry costs more than staying quiet, and per-page salvage is a feature, not a display fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 11 +++- .../integration/mcp/listSalvage-era.test.ts | 23 +++++++ .../web/src/utils/malformedListReport.test.ts | 44 +++++++++++++ clients/web/src/utils/malformedListReport.ts | 46 +++++++++++++ core/mcp/listSalvage.ts | 65 ++++++------------- 5 files changed, 144 insertions(+), 45 deletions(-) create mode 100644 clients/web/src/utils/malformedListReport.test.ts create mode 100644 clients/web/src/utils/malformedListReport.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index b42a2e3b..09645d9a 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -163,6 +163,7 @@ import { import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs"; import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; +import { visibleMalformedListItems } from "./utils/malformedListReport"; import { parseDeepLink, deepLinkConfigEquals, @@ -1056,6 +1057,14 @@ function App() { setPaginatedListsOverride(null); }, [persistedPaginatedLists, activeServerId]); const paginatedLists = paginatedListsOverride ?? persistedPaginatedLists; + // The malformed-entry report is written by the aggregate walk's salvage. In + // paginated mode the tools/prompts/resources panels render the paged stores + // instead, which never write or clear it — so it would linger above a page it + // does not describe. See `visibleMalformedListItems` (#1909 + #1721). + const shownMalformedListItems = useMemo( + () => visibleMalformedListItems(malformedListItems, paginatedLists), + [malformedListItems, paginatedLists], + ); const connected = connectionStatus === "connected"; const toolsPagination = usePaginatedList({ connected, @@ -4377,7 +4386,7 @@ function App() { latencyMs={latencyMs} tools={tools} excludedTools={excludedTools} - malformedListItems={malformedListItems} + malformedListItems={shownMalformedListItems} prompts={prompts} resources={resources} resourceTemplates={resourceTemplates} diff --git a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts index e90af022..3b570bc2 100644 --- a/clients/web/src/test/integration/mcp/listSalvage-era.test.ts +++ b/clients/web/src/test/integration/mcp/listSalvage-era.test.ts @@ -197,6 +197,29 @@ describe("salvage era item contract (#1909)", () => { ); }); + it("strips unknown fields the way the strict codec does", async () => { + // The contract validates through a wire schema; returning the ORIGINAL + // value rather than the parsed one would leave salvaged tools carrying + // fields the strict path drops, so the two paths would hand the UI + // differently-shaped tools for the same server. + const strayTool = { + name: "stray", + inputSchema: { type: "object" }, + notAToolField: "should not survive", + }; + const parsed = toolItemSchemaForEra(false).safeParse(strayTool); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data).not.toHaveProperty("notAToolField"); + + // And the strict path agrees — this is the shape being matched, not an + // invented rule. + const server = await startToolsServer([strayTool], false); + stop = server.stop; + const connected = await connect(server.url, "legacy"); + const { tools } = await connected.listAllTools(); + expect(tools[0]).not.toHaveProperty("notAToolField"); + }); + it("the era contract matches the strict path on both divergences", () => { // Legacy: rejected, exactly as the strict parse above rejected it. expect( diff --git a/clients/web/src/utils/malformedListReport.test.ts b/clients/web/src/utils/malformedListReport.test.ts new file mode 100644 index 00000000..66fd24fb --- /dev/null +++ b/clients/web/src/utils/malformedListReport.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import type { MalformedListItem } from "@inspector/core/mcp"; +import { visibleMalformedListItems } from "./malformedListReport"; + +const entry = (method: string): MalformedListItem => ({ + method, + index: 0, + reason: "annotations: expected object", +}); + +const TOOLS = entry("tools/list"); +const PROMPTS = entry("prompts/list"); +const RESOURCES = entry("resources/list"); +const TEMPLATES = entry("resources/templates/list"); + +describe("visibleMalformedListItems", () => { + it("shows every report in aggregate mode", () => { + const items = [TOOLS, PROMPTS, RESOURCES, TEMPLATES]; + expect(visibleMalformedListItems(items, false)).toEqual(items); + }); + + it("returns the same array in aggregate mode rather than a copy", () => { + // Identity is load-bearing: the value feeds a memo, and a fresh array every + // render would re-render the panels for a filter that removed nothing. + const items = [TOOLS]; + expect(visibleMalformedListItems(items, false)).toBe(items); + }); + + it("suppresses the paged lists' reports in paginated mode", () => { + // Those panels render the paged store, which neither writes nor clears this + // report — so the entry would describe a list that is no longer on screen. + expect( + visibleMalformedListItems([TOOLS, PROMPTS, RESOURCES], true), + ).toEqual([]); + }); + + it("keeps resource templates, which are aggregate-backed in both modes", () => { + // Templates have no paged store, so their report always describes the list + // actually being rendered. + expect( + visibleMalformedListItems([TOOLS, TEMPLATES, PROMPTS], true), + ).toEqual([TEMPLATES]); + }); +}); diff --git a/clients/web/src/utils/malformedListReport.ts b/clients/web/src/utils/malformedListReport.ts new file mode 100644 index 00000000..5b19b0ee --- /dev/null +++ b/clients/web/src/utils/malformedListReport.ts @@ -0,0 +1,46 @@ +import type { MalformedListItem } from "@inspector/core/mcp"; + +/** + * Which malformed-entry reports the UI may show, given the pagination mode + * (#1909 + #1721). + * + * The report is produced by the salvage fallback inside the client's `listAll*` + * aggregate walk. In **paginated** mode the tools, prompts, and resources + * panels do not render that aggregate at all — they render the paged store, + * which fetches one page at a time through a different path that neither writes + * nor clears this report. So a report written while the list was aggregated + * would simply persist, sitting above a freshly fetched page it does not + * describe: the same warning over a page that may be perfectly conforming, with + * indices that refer to positions in an aggregate the user is no longer + * looking at. + * + * Suppressing is the honest option of the two available. The alternative — + * giving the paged path its own per-page salvage and report — is a real feature + * rather than a display fix, and showing a stale warning is worse than showing + * none: this panel's whole purpose is to say which entry the server got wrong, + * so pointing at the wrong entry costs more than staying quiet. + * + * Resource templates are deliberately NOT suppressed. That list has no paged + * store — it is fetched through the aggregate in both modes — so its report is + * always about the list actually on screen. + */ +const PAGED_LIST_METHODS: readonly string[] = [ + "tools/list", + "prompts/list", + "resources/list", +]; + +/** + * The reports to render for the current pagination mode. + * + * Returns the input untouched in aggregate mode — the common case — so the + * memoized identity is preserved and nothing re-renders for a filter that + * removes nothing. + */ +export function visibleMalformedListItems( + items: MalformedListItem[], + paginated: boolean, +): MalformedListItem[] { + if (!paginated) return items; + return items.filter((item) => !PAGED_LIST_METHODS.includes(item.method)); +} diff --git a/core/mcp/listSalvage.ts b/core/mcp/listSalvage.ts index 1f2109c3..053b465b 100644 --- a/core/mcp/listSalvage.ts +++ b/core/mcp/listSalvage.ts @@ -229,21 +229,9 @@ export function describeIssues(error: z.ZodError): string { /* v8 ignore next -- zod never produces a failed parse with zero issues; guards against an empty message if it ever did */ if (!issue) return "did not match the expected shape"; const path = issue.path.join("."); - const message = describeIssue(issue); - return path.length > 0 ? `${path}: ${message}` : message; -} - -/** - * One issue's message, with the expected type folded in when it adds something. - * - * Split out from {@link describeIssues} because {@link eraToolItemSchema} has to - * apply it EARLY: forwarding another schema's issues re-emits them as `custom` - * ones, which carry a message and a path but no `expected` field, so the detail - * has to be baked into the message while the original issue is still in hand. - */ -function describeIssue(issue: z.core.$ZodIssue): string { const detail = expectedDetail(issue); - return detail ? `${issue.message} (${detail})` : issue.message; + const message = detail ? `${issue.message} (${detail})` : issue.message; + return path.length > 0 ? `${path}: ${message}` : message; } /** @@ -398,40 +386,29 @@ function stripExecution(tool: Tool): Tool { /** * Bridge a per-era WIRE schema to the `Tool` the rest of the client works with. * - * The two are deliberately separate, because on BOTH eras the wire schema - * accepts values the exported `Tool` type cannot describe — `properties: 5` is - * a valid modern `inputSchema` keyword set, and the SDK hands exactly that back - * typed as a `Tool`. So the era rules are expressed as an ordinary schema whose - * output type is irrelevant (it is only ever consulted for a verdict), and - * `z.custom<Tool>` supplies the type the caller needs. That is what avoids the - * `as unknown as` this would otherwise require — and it is the same assertion - * the SDK itself makes, in the same place. + * The bridge exists because the two cannot be the same schema: on BOTH eras the + * wire shape accepts values the exported `Tool` type cannot describe — + * `properties: 5` is a valid modern `inputSchema` keyword set, and the SDK + * hands exactly that back typed as a `Tool`. So the era rules are expressed as + * an ordinary schema, and its parsed output is asserted to `Tool` here. * - * The wire schema's issues are copied across one by one, each keeping its PATH, - * rather than collapsed into a single "invalid tool" — so `describeIssues` can - * still name the offending field (`inputSchema.type`) instead of reporting a - * bare "Invalid input", which is the whole point of the report this module - * produces. They are re-emitted as `custom` issues (the only code that can - * carry an arbitrary forwarded message), which is why the expected-type detail - * is folded into the message here by {@link describeIssue} rather than left for - * `describeIssues` to recover from a field the new issue will not have. + * The parsed OUTPUT is what gets normalized and returned, not the raw input. + * That matters: both era schemas are `z.object`, so parsing strips unknown + * top-level fields (and unknown keys inside `annotations`) exactly as the + * strict codec does. Validating with the wire schema but returning the original + * value would leave salvaged tools carrying fields the strict path drops, so + * the two paths would hand the UI differently-shaped tools for the same server. + * + * The assertion is single and deliberate — `parsed` is `unknown`, so this is + * the narrow `as` the double-cast rule asks for rather than an `as unknown as` + * — and it is the same claim the SDK makes about its own era-parsed results. + * Issues come straight from the wire parse, so they keep their field paths and + * `describeIssues` can still name `inputSchema.type` rather than reporting a + * bare "Invalid input". */ function eraToolItemSchema( wire: z.ZodType, normalize: (tool: Tool) => Tool, ): z.ZodType<Tool> { - return z - .custom<Tool>() - .superRefine((value, ctx) => { - const parsed = wire.safeParse(value); - if (parsed.success) return; - for (const issue of parsed.error.issues) { - ctx.addIssue({ - code: "custom", - path: issue.path, - message: describeIssue(issue), - }); - } - }) - .transform(normalize); + return wire.transform((parsed) => normalize(parsed as Tool)); } From 562709a79a4c4d59d344a89261c59646bb5b2a56 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 20:11:59 -0400 Subject: [PATCH 149/941] fix: guard the surviving branch's own composition, via one shared predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 10: one finding, the last position of the rule the previous two rounds established. A *non-null* branch can carry its own `anyOf`, and the hoist drops it — the branch's `anyOf` overwrites the wrapper's in the spread and is then cleared, so `{ anyOf: [{ type: "string", anyOf: [{ const: "a" }] }, { type: "null" }] }` widened into an unconstrained nullable string. Rather than add a fourth inline condition, the four sites that ask "does this schema compose something I cannot evaluate?" now share one predicate, `hasUnevaluatedComposition` (an opaque applicator, or a nested `anyOf`): - the surviving branch in the `anyOf` collapse (this round's finding), - the null branch in `admitsNull`, - the `type: [T, "null"]` collapse path, - and, via `hasOpaqueApplicator`, the top of `admitsNull`. Each of those was missed independently across rounds 8-10 for the same reason: the guard existed but was spelled out inline at each site and drifted. Sharing the predicate is the part that stops a fifth site from repeating it. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 37 +++++++++++++++++ core/json/nullableUnion.ts | 40 +++++++++++++++---- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 525dae29..2f125a82 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -450,6 +450,43 @@ describe("normalizeNullableUnion", () => { expect(normalizeNullableUnion(schema)).toBe(schema); }); + // The hoist *drops* a nested union: the branch's `anyOf` overwrites the + // wrapper's in the spread and is then cleared, so the constraint vanishes and + // the field widens into an unconstrained nullable string. + it("declines when the surviving branch carries its own anyOf", () => { + const schema = { + anyOf: [ + { type: "string", anyOf: [{ const: "a" }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(schema)).toBe(schema); + }); + + it("declines when the surviving branch carries an opaque applicator", () => { + const withNot = { + anyOf: [ + { type: "string", not: { const: "a" } }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withNot)).toBe(withNot); + const withAllOf = { + anyOf: [ + { type: "string", allOf: [{ minLength: 2 }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf); + const withOneOf = { + anyOf: [ + { type: "string", oneOf: [{ const: "a" }] }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf); + }); + it("declines when the wrapper carries an applicator the module cannot read", () => { const schema = { allOf: [{ minLength: 2 }], diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 2e4db037..7db747f4 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -205,6 +205,26 @@ function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { ); } +/** + * Whether a schema composes further constraints this module cannot evaluate — + * {@link hasOpaqueApplicator}, **plus a nested `anyOf`**. + * + * This is the single test every position uses on a schema that is *not* the + * union being recognized: the surviving branch, a null branch, and the + * `type: [T, "null"]` schema. Each of those was missed independently at some + * point — the guard existed but was spelled out inline and drifted between + * sites — so they now share one predicate rather than three conditions that + * have to be kept in step. + * + * A nested `anyOf` matters as much as the others because the hoist *drops* it: + * the branch's `anyOf` overwrites the wrapper's in the spread and is then + * cleared, so `{ anyOf: [{ type: "string", anyOf: [...] }, { type: "null" }] }` + * would otherwise widen into an unconstrained nullable string. + */ +function hasUnevaluatedComposition(schema: NullableUnionSchema): boolean { + return schema.anyOf !== undefined || hasOpaqueApplicator(schema); +} + /** * What {@link stripNullEnumMembers} concluded about an `enum`. * @@ -378,12 +398,12 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // const: "x" }` is unsatisfiable, and its own applicators are as opaque // here as the wrapper's. const branchSchema = branch as NullableUnionSchema; + // A branch that names null can still admit nothing: `{ type: "null", + // const: "x" }` is unsatisfiable, and a nested union or applicator inside + // it is as opaque here as one on the wrapper. return ( !nullExcludedBySiblings(branchSchema) && - !hasOpaqueApplicator(branchSchema) && - // A nested union inside the branch is as opaque as the others: `{ type: - // "null", anyOf: [{ type: "string" }] }` names null and admits nothing. - branchSchema.anyOf === undefined + !hasUnevaluatedComposition(branchSchema) ); }) ?? false ); @@ -424,7 +444,14 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( // Hoisting would silently drop a wrapper constraint the branch also // carries, so decline instead; see `branchDropsWrapperConstraint`. - if (nullBranch && branch && !wrapperCarriesConstraints(schema)) { + // The wrapper may only carry annotations, and the surviving branch may not + // compose anything this module cannot evaluate — the hoist would drop it. + if ( + nullBranch && + branch && + !wrapperCarriesConstraints(schema) && + !hasUnevaluatedComposition(branch as NullableUnionSchema) + ) { // A branch may carry an `enum` and no `type`; JSON Schema allows that, and // an all-string enum is unambiguously a string field. See isStringEnum for // why a non-string enum deliberately does not get the same treatment. @@ -449,8 +476,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( // and the renderers ignore the other applicators — so a compound schema // like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be // widened into an unconstrained string field. Leave it for the JSON editor. - schema.anyOf === undefined && - !hasOpaqueApplicator(schema) + !hasUnevaluatedComposition(schema) ) { const type = schema.type.find((member) => member !== "null"); if (isRenderableType(type)) { From efd0bf2b7c7eaab10b58986f68303ddd674c958a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 20:23:59 -0400 Subject: [PATCH 150/941] fix: let the aggregate own the tools/list report, and harden the scan (#1909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 9. The exclusion scan is a separate, uncached tools/list exchange, so its entries need not be the ones rendered — the server can answer differently, and a cache-served aggregate is a different list version. It was overwriting the aggregate's report wholesale, replacing accurate indices with indices into a list nobody is looking at. The aggregate now owns the report; the scan publishes only when the aggregate left nothing, which is the cache-served case that made it publish at all (round 2). A conforming re-fetch no longer swallows the mark: the list can change between the strict page and the lenient one, and a clean second response says nothing about the first, which was refused. It is marked with the original decode error when there is nothing per-item to name. The scan's fallback re-fetch now goes through invokeMcpClient, like the strict call beside it and like salvageList's walk, so an auth challenge on that request can still be recovered instead of being swallowed by listAllTools's best-effort scan catch. Also fixes listTools dropping an empty-string cursor (truthiness rather than !== undefined) — its own fallback leg already preserved it, so the two legs of one scan page disagreed about which page they had fetched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- .../inspectorClient-malformed-list.test.ts | 101 ++++++++++++++++++ core/mcp/inspectorClient.ts | 80 ++++++++++---- 2 files changed, 160 insertions(+), 21 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index c9224ff1..6a8b88cd 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -610,6 +610,107 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); + it("does not let the scan overwrite the aggregate's report", async () => { + // Both walks run on a modern connection and they are separate, uncached + // exchanges. The aggregate is the list being RENDERED, so its report owns + // the indices; the scan must not replace them with its own — the server can + // answer the second request differently, and then the warning would point + // into a list nobody is looking at. + const server = await startMalformedServer( + { + tools: [ + { + items: [ + { name: "ok_tool", inputSchema: { type: "object" } }, + { name: "broken", inputSchema: "not-a-schema" }, + ], + }, + ], + }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + + // Serve a DIFFERENT malformed shape to the scan's walk, which runs after + // the aggregate — so an overwrite is visible as a changed index/label. + let swapped = false; + const original = connected.listTools.bind(connected); + connected.listTools = async (cursor?: string, meta?: object) => { + if (!swapped) { + swapped = true; + server.setPages({ + tools: [ + { + items: [ + { name: "a", inputSchema: { type: "object" } }, + { name: "b", inputSchema: { type: "object" } }, + { name: "later_break", inputSchema: 42 }, + ], + }, + ], + }); + } + return original(cursor, meta as Record<string, string> | undefined); + }; + + await connected.listAllTools(); + + // The aggregate's finding, at the aggregate's index — not the scan's. + expect(connected.getMalformedListItems()).toEqual([ + { + method: "tools/list", + index: 1, + label: "broken", + reason: expect.stringMatching(/^inputSchema/), + }, + ]); + }); + + it("marks the scan's refused response even when the re-fetch is clean", async () => { + // The list can change between the strict page and the lenient re-fetch. A + // conforming re-fetch says nothing about the response that was refused, so + // leaving it unmarked would render a rejected exchange as a clean success. + const server = await startMalformedServer( + { tools: [{ items: [{ name: "broken", inputSchema: 42 }] }] }, + true, + ); + stopServer = server.stop; + const connected = await connectTo(server.url, "modern"); + const log = new MessageLogState(connected); + + // Let the strict call fail, then serve a conforming list to every later + // request — so the scan's re-fetch finds nothing per-item wrong. + const original = connected.listTools.bind(connected); + connected.listTools = async (cursor?: string, meta?: object) => { + try { + return await original( + cursor, + meta as Record<string, string> | undefined, + ); + } finally { + server.setPages({ + tools: [ + { items: [{ name: "fixed", inputSchema: { type: "object" } }] }, + ], + }); + } + }; + + await connected.refreshExcludedTools(); + + const marked = log + .getMessages() + .filter( + (entry) => + "method" in entry.message && + entry.message.method === "tools/list" && + entry.clientError !== undefined, + ); + expect(marked).toHaveLength(1); + log.destroy(); + }); + it("stops the salvage walk at the page cap instead of paging forever", async () => { // A server whose `nextCursor` never converges but never REPEATS slips the // repeated-cursor guard: every cursor is fresh, so the walk had no reason diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cc7f0b65..d7bb2078 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3178,7 +3178,11 @@ export class InspectorClient extends InspectorClientEventTarget { const effectiveMeta = this.mergeMeta(metadata); const params: ListToolsRequest["params"] = { ...(effectiveMeta ? { _meta: effectiveMeta } : {}), - ...(cursor ? { cursor } : {}), + // `!== undefined`, not truthiness: "" is a valid cursor. Dropping it asks + // for page one again — and `listToolsForScan` pairs this call with a + // fallback that already preserves it, so the two legs of one scan page + // would otherwise disagree about which page they fetched. + ...(cursor !== undefined && { cursor }), }; const response = await this.invokeMcpClient(() => this.client!.request( @@ -3617,13 +3621,26 @@ export class InspectorClient extends InspectorClientEventTarget { } } while (cursor !== undefined); } - // Report what the scan itself had to drop. This walk is deliberately - // un-cached, so it can see a malformed tool the `listAllTools` aggregate - // never did — that aggregate may have been served from the response cache, - // in which case its salvage never ran and cleared the report instead. Only - // ever ADDS: a scan that finds nothing must not clear a report the salvage - // path just wrote from its own walk of the same list. - if (malformed.length > 0) { + // Report what the scan itself had to drop — but only when the aggregate + // salvage left nothing to say. + // + // The aggregate OWNS this report, because the aggregate is the list the + // user is looking at. This walk is a second, deliberately un-cached + // exchange, so its entries need not be the rendered ones: the server can + // change between the two requests, and a cache-served aggregate is a + // different list version entirely. Overwriting an aggregate report with + // this one would then replace accurate indices with indices into a list + // nobody is seeing. + // + // It still publishes when the aggregate said nothing, which is the case + // this exists for: a cache-served aggregate succeeds without ever running + // salvage, so without this a malformed tool would be dropped from the scan + // with no explanation anywhere. `setMalformedListItems` is not called at + // all when empty, so a clean scan cannot clear what the aggregate wrote. + if ( + malformed.length > 0 && + this.malformedListItems.get("tools/list") === undefined + ) { this.setMalformedListItems("tools/list", malformed); } this.excludedTools = excluded; @@ -3674,17 +3691,26 @@ export class InspectorClient extends InspectorClientEventTarget { ListToolsResultSchema, "tools", ); - const page = this.isModernEra() - ? await this.rawWireRequest( - "tools/list", - this.withModernEnvelope(params), - pageSchema, - ) - : await this.client!.request( - { method: "tools/list", params }, - pageSchema, - this.getRequestOptions(metadata?.progressToken), - ); + // Through `invokeMcpClient`, like the strict `listTools` above and like + // `salvageList`'s walk: this re-fetch can meet an auth challenge of its + // own, and outside that wrapper the recovery never runs. Its failure is + // then swallowed by `listAllTools`'s best-effort scan catch, leaving a + // stale excluded-tools set and no sign of why. + const page = await this.invokeMcpClient( + () => + this.isModernEra() + ? this.rawWireRequest( + "tools/list", + this.withModernEnvelope(params), + pageSchema, + ) + : this.client!.request( + { method: "tools/list", params }, + pageSchema, + this.getRequestOptions(metadata?.progressToken), + ), + { method: "tools/list" }, + ); // Same era-envelope check as `salvageList` — the raw-wire path bypasses // the codec that would otherwise enforce it. if ( @@ -3702,10 +3728,22 @@ export class InspectorClient extends InspectorClientEventTarget { items, schema: toolItemSchemaForEra(this.isModernEra()), }); - if (rejectedResponseId !== undefined && malformed.length > 0) { + // Mark regardless of what the re-fetch found. The list can change + // between the two requests, so a conforming re-fetch is possible — and + // it says nothing about the response that was actually refused. Leaving + // that one unmarked because the SECOND request came back clean would + // render a rejected exchange as a clean success, which is the lie + // #1953 removes. With nothing per-item to name, the original decode + // error is the reason. + if (rejectedResponseId !== undefined) { this.dispatchTypedEvent("responseRejected", { id: rejectedResponseId, - reason: summarizeMalformed(malformed), + reason: + malformed.length > 0 + ? summarizeMalformed(malformed) + : err instanceof Error + ? err.message + : String(err), }); } const nextCursor = nextCursorOf(page); From 18053d4cb2dfeba7012590b407a809afd302e820 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 20:27:19 -0400 Subject: [PATCH 151/941] fix: decline empty enums, drop mislabelling enumNames, treat $ref as opaque MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 11. Two of the three are the enum-strip logic this PR added misbehaving on degenerate input; the third is one more keyword for the fail-closed rule. 1. An empty `enum` permits nothing at all, but the strip reported "unchanged" (nothing was removed) and the collapse built a Select with no options — a required field then had no interaction able to supply a value. It now reaches the same conclusion as `[null]`: no selectable member, so decline. The outcome is renamed `unselectable`, since "only-null" no longer describes both routes to it. 2. Filtering `null` out of `enum` shortens it, which could make an ALREADY mismatched `enumNames` come out the same length — so `enum: [null, "b"]` with `enumNames: ["None"]` started labelling `b` as "None", turning labels both renderers had correctly ignored into a confident mislabel. A list that was not parallel before filtering is now dropped rather than kept. 3. `$ref` joins the opaque set. It is never resolved here and applies alongside its siblings, so `{ type: "string", $ref: "#/$defs/intOnly" }` can be unsatisfiable. The wrapper path already declined on it by virtue of the annotation allowlist; this extends the same rule to branches, the `type: [T, "null"]` path, and `admitsNull`. No round 12 was requested: the review loop was stopped at the user's direction. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/nullableUnion.test.ts | 55 ++++++++++++++++++- core/json/nullableUnion.ts | 53 +++++++++++------- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 2f125a82..eb22ab2e 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -341,7 +341,27 @@ describe("normalizeNullableUnion", () => { }); }); - it("leaves a mismatched enumNames alone rather than inventing an alignment", () => { + // Keeping a mismatched list is not neutral: filtering shortens `enum`, so a + // list both renderers had correctly *ignored* can come out the same length + // and start labelling — `enum: [null, "b"]` + `enumNames: ["None"]` would + // otherwise label `b` as "None". Dropping it preserves the ignore. + it("drops a mismatched enumNames rather than letting filtering align it", () => { + expect( + normalizeNullableUnion({ + type: ["string", "null"], + enum: [null, "b"], + enumNames: ["None"], + }), + ).toEqual({ + type: "string", + enum: ["b"], + enumNames: undefined, + anyOf: undefined, + nullable: true, + }); + }); + + it("drops a mismatched enumNames that stays mismatched too", () => { expect( normalizeNullableUnion({ type: ["string", "null"], @@ -351,12 +371,43 @@ describe("normalizeNullableUnion", () => { ).toEqual({ type: "string", enum: ["a"], - enumNames: ["Alpha"], + enumNames: undefined, anyOf: undefined, nullable: true, }); }); + // An empty enum permits nothing at all, so it reaches the same conclusion as + // `[null]`: no value a dropdown could offer, so do not build one. + it("declines to collapse an empty enum", () => { + const typeArray = { type: ["string", "null"], enum: [] }; + expect(normalizeNullableUnion(typeArray)).toBe(typeArray); + const union = { + anyOf: [{ type: "string", enum: [] }, { type: "null" as const }], + }; + expect(normalizeNullableUnion(union)).toBe(union); + }); + + // `$ref` applies alongside its siblings and is never resolved here, so a + // branch carrying one can be unsatisfiable. + it("treats $ref as opaque wherever it appears", () => { + const onBranch = { + anyOf: [ + { type: "string", $ref: "#/$defs/intOnly" }, + { type: "null" as const }, + ], + }; + expect(normalizeNullableUnion(onBranch)).toBe(onBranch); + const onTypeArray = { type: ["string", "null"], $ref: "#/$defs/intOnly" }; + expect(normalizeNullableUnion(onTypeArray)).toBe(onTypeArray); + expect(admitsNull({ type: ["string", "null"], $ref: "#/$defs/x" })).toBe( + false, + ); + expect(admitsNull({ anyOf: [{ type: "null", $ref: "#/$defs/x" }] })).toBe( + false, + ); + }); + // The type-array path keeps the schema's own keywords, but `collapsed` clears // `anyOf` and the renderers ignore the other applicators — so a compound // schema would be widened into an unconstrained field rather than collapsed. diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 7db747f4..e4cc8b24 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -47,6 +47,7 @@ export interface NullableUnionSchema { oneOf?: readonly unknown[]; allOf?: readonly unknown[]; not?: unknown; + $ref?: string; nullable?: boolean; const?: unknown; } @@ -201,7 +202,12 @@ function hasOpaqueApplicator(schema: NullableUnionSchema): boolean { return ( schema.not !== undefined || schema.allOf !== undefined || - schema.oneOf !== undefined + schema.oneOf !== undefined || + // `$ref` points at a schema this module never resolves, and it applies + // *alongside* its siblings — `{ type: "string", $ref: "#/$defs/intOnly" }` + // can be unsatisfiable. The wrapper path already declines on it (it is not + // an annotation); this is the same rule for every other position. + schema.$ref !== undefined ); } @@ -228,13 +234,13 @@ function hasUnevaluatedComposition(schema: NullableUnionSchema): boolean { /** * What {@link stripNullEnumMembers} concluded about an `enum`. * - * `"only-null"` is a distinct outcome rather than "an empty list" because it + * `"unselectable"` is a distinct outcome rather than "an empty list" because it * has to **stop the collapse**, not just empty a field. See below. */ type EnumStrip = | { kind: "unchanged" } | { kind: "filtered"; members: unknown[]; names?: unknown } - | { kind: "only-null" }; + | { kind: "unselectable" }; /** * Drop `null` members from an `enum`, keeping the parallel `enumNames` aligned. @@ -252,12 +258,13 @@ type EnumStrip = * the two lengths disagree — so stripping one without the other silently loses * every label rather than just the dropped one's. * - * **An enum of nothing but `null` reports `"only-null"` so the caller declines - * to collapse at all.** Emitting `enum: undefined` instead would turn a schema - * permitting *only* `null` into a plain string field that accepts arbitrary - * text — trading a cosmetic problem for a correctness one, since the form would - * then invite values the schema forbids. Left uncollapsed it renders through - * the JSON editor, which represents it honestly. + * **An enum that offers no selectable value reports `"unselectable"`, so the + * caller declines to collapse at all.** That covers `[null]` and the empty + * `[]`: the first permits only `null`, the second permits nothing. Emitting + * `enum: undefined` instead would turn either into a plain string field + * accepting arbitrary text — trading a cosmetic problem for a correctness one, + * since the form would then invite values the schema forbids. Left uncollapsed + * they render through the JSON editor, which represents them honestly. */ function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip { if (!Array.isArray(members)) { @@ -266,19 +273,23 @@ function stripNullEnumMembers(members: unknown, names: unknown): EnumStrip { const kept = members .map((member, index) => ({ member, index })) .filter((entry) => entry.member !== null); + // An empty `enum` permits nothing at all, so it reaches the same conclusion + // as `[null]` by a different route: there is no value a dropdown could offer. + if (kept.length === 0) { + return { kind: "unselectable" }; + } if (kept.length === members.length) { return { kind: "unchanged" }; } - if (kept.length === 0) { - return { kind: "only-null" }; - } - // Only realign names that were positionally parallel to begin with; a - // mismatched list is already ignored by both renderers, so re-indexing it - // would invent an alignment the server never declared. - const aligned = - Array.isArray(names) && names.length === members.length - ? kept.map((entry) => names[entry.index]) - : names; + // Only realign names that were positionally parallel to begin with, and DROP + // them otherwise. Keeping a mismatched list is not neutral: filtering shortens + // `enum`, so `enum: [null, "b"]` with `enumNames: ["None"]` would come out the + // same length and start *labelling* `b` as "None" — turning labels both + // renderers had correctly ignored into a confident mislabel. + const wasParallel = Array.isArray(names) && names.length === members.length; + const aligned = wasParallel + ? kept.map((entry) => names[entry.index]) + : undefined; return { kind: "filtered", members: kept.map((entry) => entry.member), @@ -309,7 +320,7 @@ function collapsed<T extends NullableUnionSchema>( merged.enum, (merged as { enumNames?: unknown }).enumNames, ); - if (strip.kind === "only-null") { + if (strip.kind === "unselectable") { return null; } return { @@ -458,7 +469,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>( const type = branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined); if (isRenderableType(type)) { - // `null` when the merged enum permits only `null`, which is not + // `null` when the merged enum offers nothing selectable, which is not // collapsible — fall through and return the schema by identity. const result = collapsed(schema, branch, type); if (result !== null) { From 77ff30bbc940ffdab717f781530eff15a4d8087a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 20:38:10 -0400 Subject: [PATCH 152/941] fix: stop the exclusion scan writing the rendered list's report (#1909) Copilot round 10. The scan no longer publishes its malformed entries into the tools/list report at all. Round 9 only stopped it overwriting the aggregate's; the remaining case is unsalvageable for a stronger reason than 'two different requests': - a FAILED strict aggregate runs salvage, which writes the report itself; - a SUCCESSFUL one means every entry parsed, so the rendered list contains no malformed entries. So by the time the scan finds something, either the aggregate already reported it with indices matching the screen, or the aggregate succeeded and what the scan found is provably not in the list being displayed. Publishing could only ever name entries the user cannot see. Nothing is lost: listToolsForScan still marks its own refused response, so the finding surfaces on the Protocol entry for the exchange that carried it, where it is true. The per-page accumulation and raw-entry offset existed only to feed the removed publish, and go with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDXCSrnSBV7vZfxycG5Z6E Signed-off-by: cliffhall <cliff@futurescale.com> --- .../inspectorClient-malformed-list.test.ts | 20 +++--- core/mcp/inspectorClient.ts | 65 +++++++------------ 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts index 6a8b88cd..64dcafbe 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-malformed-list.test.ts @@ -535,10 +535,12 @@ describe("InspectorClient list salvage (#1909)", () => { log.destroy(); }); - it("reports scan-dropped tools at their position in the whole list", async () => { - // The exclusion scan walks pages of its own, so its report has to be - // offset by the RAW entries of PRIOR pages. An unlabeled entry's index is - // all the user has to go on, so a wrong offset points at nothing. + it("indexes a dropped tool against the whole aggregate, across pages", async () => { + // The reported index has to be offset by the RAW entries of PRIOR pages — + // valid and malformed alike, not just the kept ones. An unlabeled entry's + // index is all the user has to go on, so a wrong offset points at nothing. + // This is the aggregate salvage's walk: it owns the `tools/list` report, + // because it is the walk that produced the list being rendered. const server = await startMalformedServer( { tools: [ @@ -610,12 +612,12 @@ describe("InspectorClient list salvage (#1909)", () => { expect(connected.getMalformedListItems()).toEqual([]); }); - it("does not let the scan overwrite the aggregate's report", async () => { + it("keeps the aggregate's report when the scan sees a different list", async () => { // Both walks run on a modern connection and they are separate, uncached - // exchanges. The aggregate is the list being RENDERED, so its report owns - // the indices; the scan must not replace them with its own — the server can - // answer the second request differently, and then the warning would point - // into a list nobody is looking at. + // exchanges. The aggregate is the list being RENDERED, so it owns the + // report; the scan never writes it. Here the server answers the scan's + // request with a different malformed shape — which, if the scan published, + // would leave the warning pointing into a list nobody is looking at. const server = await startMalformedServer( { tools: [ diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d7bb2078..333020aa 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3579,10 +3579,6 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?: Record<string, string>, ): Promise<ExcludedTool[]> { const excluded: ExcludedTool[] = []; - const malformed: MalformedListItem[] = []; - // Raw entries seen so far, across pages — the frame the reported indices - // are relative to (see the mapping below). - let rawOffset = 0; // Gated to connections that actually exclude; otherwise this is a pure // no-op (no round trip). The raw `listTools` below guards the connection. if (this.excludesInvalidXMcpHeaderTools()) { @@ -3597,18 +3593,6 @@ export class InspectorClient extends InspectorClientEventTarget { if (pages >= LIST_MAX_PAGES) throw listPaginationExceeded("tools/list"); pages += 1; const page = await this.listToolsForScan(cursor, metadata); - malformed.push( - ...page.malformed.map((entry) => ({ - ...entry, - // Offset by the RAW entries of PRIOR pages only. `excluded.length` - // would be wrong twice over: it counts just the invalid-header - // tools, and on page one it would also fold in this page's own - // valid count — putting an unlabeled entry, whose index is all the - // user has to go on, at a position that doesn't exist. - index: entry.index + rawOffset, - })), - ); - rawOffset += page.tools.length + page.malformed.length; for (const tool of page.tools) { const scan = scanXMcpHeaderDeclarations(tool.inputSchema); if (!scan.valid) excluded.push({ tool, reason: scan.reason }); @@ -3621,28 +3605,24 @@ export class InspectorClient extends InspectorClientEventTarget { } } while (cursor !== undefined); } - // Report what the scan itself had to drop — but only when the aggregate - // salvage left nothing to say. + // The scan deliberately does NOT publish what it dropped into the + // `tools/list` report. That report is rendered above the Tools list as + // "entries dropped from this list", and the aggregate is what that list + // is — so only the aggregate's salvage may write it. // - // The aggregate OWNS this report, because the aggregate is the list the - // user is looking at. This walk is a second, deliberately un-cached - // exchange, so its entries need not be the rendered ones: the server can - // change between the two requests, and a cache-served aggregate is a - // different list version entirely. Overwriting an aggregate report with - // this one would then replace accurate indices with indices into a list - // nobody is seeing. + // The reason is stronger than "these are two different requests". A + // successful strict aggregate means every entry parsed, so the rendered + // list has no malformed entries in it at all; and a FAILED aggregate runs + // salvage, which writes the report itself. So by the time this walk finds + // something, either the aggregate already reported it (with the indices + // that match what is on screen), or the aggregate succeeded and whatever + // this walk just found is provably not in the list being displayed — the + // server changed, or the aggregate came from cache. Publishing here can + // therefore only ever name entries the user cannot see. // - // It still publishes when the aggregate said nothing, which is the case - // this exists for: a cache-served aggregate succeeds without ever running - // salvage, so without this a malformed tool would be dropped from the scan - // with no explanation anywhere. `setMalformedListItems` is not called at - // all when empty, so a clean scan cannot clear what the aggregate wrote. - if ( - malformed.length > 0 && - this.malformedListItems.get("tools/list") === undefined - ) { - this.setMalformedListItems("tools/list", malformed); - } + // The finding is not lost: `listToolsForScan` marks the scan's own refused + // response, so it surfaces on the Protocol entry for the exchange that + // actually carried it, which is where it is true. this.excludedTools = excluded; this.dispatchTypedEvent("excludedToolsChange", excluded); return excluded; @@ -3657,8 +3637,12 @@ export class InspectorClient extends InspectorClientEventTarget { * which would blank the excluded set and leave a tool that vanished for an * invalid header with no explanation at all. That is the same failure #1909 * is about, one level down. So on a decode rejection the page is re-fetched - * leniently and scanned entry by entry; the malformed ones are not "excluded - * tools" and are reported through the salvage channel instead. + * leniently and scanned entry by entry. + * + * The malformed ones are not "excluded tools" and are not returned: they + * describe THIS exchange, not the aggregate the Tools panel renders, so they + * surface as the rejection reason on this response's own Protocol entry and + * go no further (see `refreshExcludedTools`). */ private async listToolsForScan( cursor: string | undefined, @@ -3666,11 +3650,9 @@ export class InspectorClient extends InspectorClientEventTarget { ): Promise<{ tools: Tool[]; nextCursor?: string; - malformed: MalformedListItem[]; }> { try { - const page = await this.listTools(cursor, metadata); - return { ...page, malformed: [] }; + return await this.listTools(cursor, metadata); } catch (err) { if (!isSalvageableRejection(err)) throw err; // Capture before the re-fetch, for the reason `salvageList` documents: @@ -3750,7 +3732,6 @@ export class InspectorClient extends InspectorClientEventTarget { return { tools: valid, ...(nextCursor !== undefined && { nextCursor }), - malformed, }; } catch (fallbackErr) { // The fallback could not explain the rejection — the re-fetch itself From 3a22d4eedca032d43fa5e0e88d2f4df58b2e987a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:13:11 -0400 Subject: [PATCH 153/941] fix: disambiguate the two token rows' controls for screen readers (#2019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 1. With an ID Token row alongside the Access Token row, both can render a "Decode JWT" toggle and a "Copy" control, and the four buttons shared two accessible names — button-by-button screen-reader navigation could not tell which token a control acted on. That collision is introduced by this PR: before it there was only ever one token row. - `CopyButton` takes an optional `label` naming what is being copied, used for the accessible name only ("Copy ID Token"). The visible tooltip stays "Copy", and the name keeps "Copy"/"Copied" as its first word so it still contains the visible text (WCAG 2.5.3 Label in Name). - `OAuthTokenField` qualifies its decode toggle the same way ("Decode JWT for Access Token") and passes its row label down to `CopyButton`. The visible UI is unchanged — this is accessible-name only. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../elements/CopyButton/CopyButton.test.tsx | 11 ++++++ .../elements/CopyButton/CopyButton.tsx | 14 ++++++- .../ConnectionInfoContent.stories.tsx | 4 +- .../ConnectionInfoContent.test.tsx | 35 +++++++++++++++++ .../OAuthTokenField.test.tsx | 39 +++++++++++++++---- .../ConnectionInfoContent/OAuthTokenField.tsx | 3 +- 6 files changed, 94 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx index 9a4b462a..0b663cae 100644 --- a/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx +++ b/clients/web/src/components/elements/CopyButton/CopyButton.test.tsx @@ -10,6 +10,17 @@ describe("CopyButton", () => { expect(screen.getByText("⎘")).toBeInTheDocument(); }); + it("names itself 'Copy' with no label, and qualifies the name when given one", () => { + const { unmount } = renderWithMantine(<CopyButton value="hello" />); + expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + unmount(); + + renderWithMantine(<CopyButton value="hello" label="ID Token" />); + expect( + screen.getByRole("button", { name: "Copy ID Token" }), + ).toBeInTheDocument(); + }); + it("triggers a click without throwing when clipboard is unavailable", async () => { const user = userEvent.setup(); renderWithMantine(<CopyButton value="hello" />); diff --git a/clients/web/src/components/elements/CopyButton/CopyButton.tsx b/clients/web/src/components/elements/CopyButton/CopyButton.tsx index 7a86ca08..5b87719d 100644 --- a/clients/web/src/components/elements/CopyButton/CopyButton.tsx +++ b/clients/web/src/components/elements/CopyButton/CopyButton.tsx @@ -11,6 +11,15 @@ export interface CopyButtonProps { * rows (e.g. beside a Code block). Icon size is unchanged. */ flush?: boolean; + /** + * Names what is being copied, for the accessible label only ("Copy ID + * Token"). Set it when more than one CopyButton can appear in the same + * region, so button-by-button screen-reader navigation can tell them apart; + * a lone control needs no qualifier. The visible tooltip stays "Copy" either + * way, and the accessible name keeps "Copy"/"Copied" as its first word so it + * still contains the visible text (WCAG 2.5.3). + */ + label?: string; } const CopyActionIcon = ActionIcon.withProps({ @@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({ fz: 24, }); -export function CopyButton({ value, flush = false }: CopyButtonProps) { +export function CopyButton({ value, flush = false, label }: CopyButtonProps) { + const qualifier = label ? ` ${label}` : ""; return ( <MantineCopyButton value={value}> {({ copied, copy }) => ( @@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) { <CopyActionIcon color={copied ? "green" : "var(--inspector-text-primary)"} onClick={copy} - aria-label={copied ? "Copied" : "Copy"} + aria-label={(copied ? "Copied" : "Copy") + qualifier} {...(flush && { p: 0, h: "auto", w: "auto" })} > {copied ? "\u2713" : "\u2398"} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index a634b9de..c6f1e598 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -189,7 +189,9 @@ export const WithOAuthIdToken: Story = { const canvas = within(canvasElement); expect(canvas.getByText("ID Token")).toBeInTheDocument(); // Only the ID token is a JWT here, so exactly one decode toggle is offered. - await userEvent.click(canvas.getByRole("button", { name: "Decode JWT" })); + await userEvent.click( + canvas.getByRole("button", { name: "Decode JWT for ID Token" }), + ); expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index e33c63ad..f9f3d2fa 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -495,6 +495,41 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("id-token-456")).toBeInTheDocument(); }); + it("gives the two token rows' controls distinct accessible names", () => { + // Both rows carry a copy control, and both tokens here are JWTs so both + // carry a decode toggle. Screen-reader button navigation has only the + // accessible name to go on, so the four must not collide (#2019 review). + const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."; + renderWithMantine( + <ConnectionInfoContent + initializeResult={fullResult} + clientCapabilities={fullClientCaps} + transport="streamable-http" + oauth={{ + protocol: "standard", + authorized: true, + accessToken: jwt, + idToken: jwt, + }} + />, + ); + + const names = screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label") ?? button.textContent) + .filter((name): name is string => Boolean(name)); + expect(new Set(names).size).toBe(names.length); + + for (const name of [ + "Copy Access Token", + "Copy ID Token", + "Decode JWT for Access Token", + "Decode JWT for ID Token", + ]) { + expect(screen.getByRole("button", { name })).toBeInTheDocument(); + } + }); + it("renders the ID Token row on its own when there is no access token", () => { renderWithMantine( <ConnectionInfoContent diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx index 188c05a7..a46e6538 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.test.tsx @@ -12,7 +12,9 @@ describe("OAuthTokenField", () => { expect(screen.getByText("Access Token")).toBeInTheDocument(); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy Access Token" }), + ).toBeInTheDocument(); }); it("replaces raw token with decoded JSON and restores on toggle", async () => { @@ -20,11 +22,15 @@ describe("OAuthTokenField", () => { renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for Access Token" }), + ); expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Show token" })); + await user.click( + screen.getByRole("button", { name: "Show token for Access Token" }), + ); expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument(); expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument(); }); @@ -37,7 +43,7 @@ describe("OAuthTokenField", () => { />, ); expect( - screen.queryByRole("button", { name: "Decode JWT" }), + screen.queryByRole("button", { name: "Decode JWT for Access Token" }), ).not.toBeInTheDocument(); }); @@ -50,8 +56,10 @@ describe("OAuthTokenField", () => { }); renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); - await user.click(screen.getByRole("button", { name: "Copy" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for Access Token" }), + ); + await user.click(screen.getByRole("button", { name: "Copy Access Token" })); expect(writeText).toHaveBeenCalledWith( expect.stringContaining('"sub": "user"'), @@ -68,7 +76,7 @@ describe("OAuthTokenField", () => { }); renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />); - await user.click(screen.getByRole("button", { name: "Copy" })); + await user.click(screen.getByRole("button", { name: "Copy Access Token" })); expect(writeText).toHaveBeenCalledWith(jwt); }); @@ -101,10 +109,25 @@ describe("OAuthTokenField", () => { expect(screen.getByText("ID Token")).toBeInTheDocument(); expect(screen.queryByText("Access Token")).not.toBeInTheDocument(); - await user.click(screen.getByRole("button", { name: "Decode JWT" })); + await user.click( + screen.getByRole("button", { name: "Decode JWT for ID Token" }), + ); expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument(); }); + it("qualifies the decode and copy accessible names with the row label", () => { + // Two rows can render simultaneously (Access Token + ID Token), so the + // controls must not share accessible names — see #2019 review. + renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />); + + expect( + screen.getByRole("button", { name: "Decode JWT for ID Token" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Copy ID Token" }), + ).toBeInTheDocument(); + }); + it("omits the clear action when no handler is given", () => { renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />); expect( diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx index a9c19b2d..abd5c4a9 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthTokenField.tsx @@ -107,6 +107,7 @@ export function OAuthTokenField({ <ToolbarButton onClick={() => setShowDecoded((open) => !open)} aria-pressed={showDecoded} + aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`} > {showDecoded ? "Show token" : "Decode JWT"} </ToolbarButton> @@ -130,7 +131,7 @@ export function OAuthTokenField({ )} </TokenCode> </TokenColumn> - <CopyButton value={copyValue} flush /> + <CopyButton value={copyValue} flush label={label} /> </TokenRow> </Stack> ); From 362e2f419d4bec438eae62b180b6c8a376a45a2a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:14:21 -0400 Subject: [PATCH 154/941] test: build a real OAuthStorage in the EMA leak test (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 1: the new EMA regression test constructed its inner provider's storage with `as unknown as OAuthStorage`, an unjustified double cast — the rule in AGENTS.md is that such a cast must carry a justification or be replaced. Replaced with the genuine article: `OAuthStorageBase` over an `OAuthMemoryStore` and a no-op persist backend, so the stub type-checks against the real interface the provider is handed and a future `OAuthStorage` contract change cannot pass silently. Reusing `emaConfig.storage`, as suggested, would not have addressed the objection — that value is itself a cast, and typed as `EmaFlowConfig["storage"]` rather than `OAuthStorage`. The test's assertion is unchanged and was re-verified to fail against pre-fix code (`expected 'corp-idp' to be null`). Signed-off-by: cliffhall <cliff@futurescale.com> --- .../core/auth/ema/transportProvider.test.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index 7cf3acce..b6a3b32a 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -6,6 +6,9 @@ import { MutableRedirectUrlProvider, } from "@inspector/core/auth/providers.js"; import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js"; +import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; +import type { OAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js"; import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js"; import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js"; import { @@ -23,6 +26,20 @@ const startIdpMock = vi.mocked(startEmaIdpAuthorization); const SERVER_URL = "http://127.0.0.1:9999/mcp"; +/** + * A real `OAuthStorage` — in-memory state over a no-op persist backend — for the + * one test below that drives an actual `BaseOAuthClientProvider`. Building the + * genuine article costs two lines and avoids an `as unknown as` stub that would + * stop type-checking against the interface the provider is handed. + */ +function makeRealStorage(): OAuthStorage { + const backend: OAuthPersistBackend = { + read: async () => null, + write: async () => {}, + }; + return new OAuthStorageBase(new OAuthMemoryStore(), backend); +} + function jwtWithExp(expSec: number): string { const payload = btoa(JSON.stringify({ exp: expSec })) .replace(/\+/g, "-") @@ -200,9 +217,10 @@ describe("EmaTransportOAuthProvider", () => { it("does not leak custom authorization params onto the IdP authorize URL", async () => { const navCallback = vi.fn(); const realInner = new BaseOAuthClientProvider(SERVER_URL, { - storage: { - getScope: vi.fn().mockResolvedValue(undefined), - } as unknown as OAuthStorage, + // A real OAuthStorage rather than a cast stub: the redirect path never + // reads it, and a genuine implementation keeps this test honest if the + // storage contract changes. + storage: makeRealStorage(), redirectUrlProvider: new MutableRedirectUrlProvider(), navigation: new CallbackNavigation(navCallback), authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" }, From 6002c2ce3359cd4bf2bbf8301102ead29387e228 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:09:51 -0400 Subject: [PATCH 155/941] fix: gate submission on unsendable field text, and on required tool args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field that cannot turn its text into a value reports `undefined` — which is exactly what an empty field reports. That made the two indistinguishable to every caller of `SchemaForm`, so invalid text in an optional field was submittable and the argument simply arrived absent (#2020). `SchemaForm` now tracks per-field draft validity and reports it through `onValidityChange`, covering both fields that hold text the form cannot send: the JSON editor (unparseable text) and the number input (a magnitude JS cannot hold exactly, which is dropped rather than silently rounded). The number field also states it on itself, as the JSON editor already did. A nested object form reports through the same channel, and both the field-level and form-level effects report "valid" on cleanup so a draft cannot outlive the field that held it — a remount under a new `resetKey`, or an unmounted panel. `ToolDetailPanel` gated Execute on `isExecuting` alone, so it also had no required-argument check at all — unlike the App and elicitation panels. It now uses `hasMissingRequiredFields` like they do. `InlineElicitationRequest` had neither gate and gets both. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../AppDetailPanel/AppDetailPanel.test.tsx | 26 +++ .../groups/AppDetailPanel/AppDetailPanel.tsx | 9 +- .../ElicitationFormPanel.test.tsx | 28 +++ .../ElicitationFormPanel.tsx | 9 +- .../InlineElicitationRequest.test.tsx | 66 ++++++ .../InlineElicitationRequest.tsx | 24 ++- .../groups/SchemaForm/SchemaForm.test.tsx | 193 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 160 +++++++++++++-- .../ToolDetailPanel/ToolDetailPanel.test.tsx | 66 +++++- .../ToolDetailPanel/ToolDetailPanel.tsx | 21 +- 10 files changed, 582 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx index effbd5a2..ad14b1c3 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.test.tsx @@ -173,4 +173,30 @@ describe("AppDetailPanel", () => { rerender(<AppDetailPanel {...baseProps} tool={numberFieldTool("app_b")} />); expect(input().value).toBe(""); }); + + // #2020: `hasMissingRequiredFields` sees a field with no value, but text a + // field could not turn into a value looks identical to it — both report + // `undefined` — so an optional argument was dropped from the call with no + // gate. The form reports draft validity for this reason. + it("disables Open App while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + const jsonFieldTool: Tool = { + name: "chart_app", + title: "Chart App", + inputSchema: { + type: "object", + properties: { series: { type: "array", title: "Series" } }, + }, + }; + renderWithMantine(<AppDetailPanel {...baseProps} tool={jsonFieldTool} />); + const openApp = screen.getByRole("button", { name: /open app/i }); + expect(openApp).not.toBeDisabled(); + + const jsonInput = screen.getByLabelText(/Series/); + await user.type(jsonInput, "x"); + expect(openApp).toBeDisabled(); + + await user.clear(jsonInput); + expect(openApp).not.toBeDisabled(); + }); }); diff --git a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx index 8d5a2135..8d75f431 100644 --- a/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx +++ b/clients/web/src/components/groups/AppDetailPanel/AppDetailPanel.tsx @@ -1,4 +1,5 @@ import { Button, Divider, ScrollArea, Stack, Text } from "@mantine/core"; +import { useState } from "react"; import { MdPlayArrow } from "react-icons/md"; import type { Tool } from "@modelcontextprotocol/client"; import { SchemaForm } from "../SchemaForm/SchemaForm"; @@ -58,7 +59,12 @@ export function AppDetailPanel({ /* v8 ignore next -- unreachable: Tool.inputSchema is always an object */ const formSchema = toFormSchema(inputSchema) ?? {}; const hasErrors = hasMissingRequiredFields(formSchema, formValues); - const disabled = isOpening || hasErrors; + // A field holding text it could not turn into a value (unparseable JSON, an + // unrepresentable number) reports `undefined`, which is indistinguishable + // from empty once it reaches `formValues` — so the form reports it directly + // and an optional argument can no longer be dropped silently (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); + const disabled = isOpening || hasErrors || hasInvalidDraft; const hasFields = hasInputFields(tool); return ( @@ -82,6 +88,7 @@ export function AppDetailPanel({ // tool's name to drop another app's in-progress field text. See // SchemaFormProps.resetKey. resetKey={tool.name} + onValidityChange={setHasInvalidDraft} /> <OpenAppButton diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx index d78e9171..87df919f 100644 --- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx +++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.test.tsx @@ -126,4 +126,32 @@ describe("ElicitationFormPanel", () => { await user.type(screen.getByLabelText("Host"), "l"); expect(onChange).toHaveBeenCalledWith({ host: "l" }); }); + + // #2020: text a field cannot turn into a value reports `undefined`, so it is + // indistinguishable from an empty field in `values` — Submit stayed enabled + // and the answer went out without the property. + it("disables Submit while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + const numberRequest: ElicitRequestFormParams = { + message: "Please provide the port.", + requestedSchema: { + type: "object" as const, + properties: { port: { type: "number" as const, title: "Port number" } }, + }, + }; + renderWithMantine( + <ElicitationFormPanel {...baseProps} request={numberRequest} />, + ); + const submit = screen.getByRole("button", { name: "Submit" }); + expect(submit).toBeEnabled(); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so the answer would go out without the property. + const input = screen.getByLabelText(/Port number/); + await user.type(input, "90071992547409910"); + expect(submit).toBeDisabled(); + + await user.clear(input); + expect(submit).toBeEnabled(); + }); }); diff --git a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx index 5ec4fddc..07802433 100644 --- a/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx +++ b/clients/web/src/components/groups/ElicitationFormPanel/ElicitationFormPanel.tsx @@ -7,6 +7,7 @@ import { Stack, Text, } from "@mantine/core"; +import { useState } from "react"; import type { ElicitRequestFormParams } from "@modelcontextprotocol/client"; import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; import { @@ -88,8 +89,13 @@ export function ElicitationFormPanel({ busy = false, }: ElicitationFormPanelProps) { const requestedSchema = request.requestedSchema as InspectorFormSchema; + // Text a field could not turn into a value never reaches `values`, so the + // form has to report it for Submit to see it at all (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); const submitDisabled = - busy || hasMissingRequiredFields(requestedSchema, values); + busy || + hasMissingRequiredFields(requestedSchema, values) || + hasInvalidDraft; return ( <Stack gap="md"> <QuotedMessage>{formatQuoted(request.message)}</QuotedMessage> @@ -101,6 +107,7 @@ export function ElicitationFormPanel({ values={values} onChange={onChange} disabled={busy} + onValidityChange={setHasInvalidDraft} /> </FieldScroll> <WarningAlert>{formatWarning(serverName)}</WarningAlert> diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx index ea065596..b988e2b0 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.test.tsx @@ -161,4 +161,70 @@ describe("InlineElicitationRequest", () => { ); expect(screen.getByText("Host")).toBeInTheDocument(); }); + + // #2020: this inline form had no submit gate at all — neither the missing + // required answer that `ElicitationFormPanel` already checked, nor text a + // field could not turn into a value (which never reaches `values`). + describe("submit gating (#2020)", () => { + const requiredRequest = { + message: "Please provide your name.", + requestedSchema: { + type: "object" as const, + properties: { name: { type: "string" as const, title: "Name" } }, + required: ["name"], + }, + } satisfies ElicitRequest["params"]; + + const numberRequest = { + message: "Please provide the port.", + requestedSchema: { + type: "object" as const, + properties: { port: { type: "number" as const, title: "Port number" } }, + }, + } satisfies ElicitRequest["params"]; + + it("disables Submit while a required answer is missing", () => { + renderWithMantine( + <InlineElicitationRequest + {...baseProps} + request={requiredRequest} + values={{}} + />, + ); + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + }); + + it("enables Submit once the required answer is given", () => { + renderWithMantine( + <InlineElicitationRequest + {...baseProps} + request={requiredRequest} + values={{ name: "Ada" }} + />, + ); + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + }); + + it("disables Submit while a field holds text it cannot send", async () => { + const user = userEvent.setup(); + renderWithMantine( + <InlineElicitationRequest + {...baseProps} + request={numberRequest} + values={{}} + />, + ); + const submit = screen.getByRole("button", { name: "Submit" }); + expect(submit).toBeEnabled(); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so the answer would go out without the property. + const input = screen.getByLabelText(/Port number/); + await user.type(input, "90071992547409910"); + expect(submit).toBeDisabled(); + + await user.clear(input); + expect(submit).toBeEnabled(); + }); + }); }); diff --git a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx index 14bc6fe8..7a85e6d2 100644 --- a/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx +++ b/clients/web/src/components/groups/InlineElicitationRequest/InlineElicitationRequest.tsx @@ -13,7 +13,11 @@ import type { ElicitRequestFormParams, } from "@modelcontextprotocol/client"; import type { PendingRequestOrigin } from "@inspector/core/mcp/types.js"; -import type { InspectorFormSchema } from "../../../utils/jsonUtils"; +import { useState } from "react"; +import { + hasMissingRequiredFields, + type InspectorFormSchema, +} from "../../../utils/jsonUtils"; import { SchemaForm } from "../SchemaForm/SchemaForm"; import { MrtrOriginNote } from "../../elements/MrtrOriginNote/MrtrOriginNote"; @@ -86,6 +90,19 @@ export function InlineElicitationRequest({ onSubmit, onCancel, }: InlineElicitationRequestProps) { + // Same gate as `ElicitationFormPanel`, which this is the inline form of: a + // missing required answer, or a field holding text it could not turn into a + // value, must not be submittable — neither is visible in `values` (#2020). + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); + const formValues = values ?? {}; + const submitDisabled = + isFormMode(request) && + (hasMissingRequiredFields( + request.requestedSchema as InspectorFormSchema, + formValues, + ) || + hasInvalidDraft); + return ( <RequestContainer> <Stack gap="sm"> @@ -100,8 +117,9 @@ export function InlineElicitationRequest({ {isFormMode(request) && ( <SchemaForm schema={request.requestedSchema as InspectorFormSchema} - values={values ?? {}} + values={formValues} onChange={onChange} + onValidityChange={setHasInvalidDraft} /> )} @@ -120,7 +138,7 @@ export function InlineElicitationRequest({ <ActionsRow> <CompactButton onClick={onCancel}>Cancel</CompactButton> {isFormMode(request) && ( - <Button size="xs" onClick={onSubmit}> + <Button size="xs" onClick={onSubmit} disabled={submitDisabled}> Submit </Button> )} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index c2392ec8..6c7dd77b 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1284,3 +1284,196 @@ describe("SchemaForm nullable unions", () => { expect(screen.getByLabelText(/Mixed/).tagName).toBe("TEXTAREA"); }); }); + +// #2020: a field holding text it cannot turn into a value reports `undefined`, +// which is exactly what an *empty* field reports. That makes the two states +// indistinguishable to the caller, so invalid text in an optional field was +// submittable and simply arrived at the server absent. The form reports draft +// validity directly so a submit gate can see what `values` cannot. +describe("SchemaForm draft validity (#2020)", () => { + const jsonSchema: InspectorFormSchema = { + type: "object", + properties: { + config: { type: "array", title: "Config" }, + }, + }; + + function ValidityHarness({ + schema, + onValidityChange, + resetKey, + }: { + schema: InspectorFormSchema; + onValidityChange: (hasInvalidDraft: boolean) => void; + resetKey?: string; + }) { + const [values, setValues] = useState<Record<string, unknown>>({}); + return ( + <SchemaForm + schema={schema} + values={values} + onChange={setValues} + resetKey={resetKey} + onValidityChange={onValidityChange} + /> + ); + } + + it("reports an empty optional field as valid", () => { + const onValidityChange = vi.fn(); + renderWithMantine( + <ValidityHarness + schema={jsonSchema} + onValidityChange={onValidityChange} + />, + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports an unparseable JSON draft, then clears it once the text parses", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + renderWithMantine( + <ValidityHarness + schema={jsonSchema} + onValidityChange={onValidityChange} + />, + ); + + const jsonInput = screen.getByLabelText(/Config/) as HTMLTextAreaElement; + await user.type(jsonInput, "[[1,"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.type(jsonInput, "2]"); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports a number this client cannot send exactly, and says so on the field", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + divisor: { type: "number", title: "Divisor" }, + }, + }; + renderWithMantine( + <ValidityHarness schema={schema} onValidityChange={onValidityChange} />, + ); + + // Past MAX_SAFE_INTEGER the field reports no value rather than a rounded + // one, so — like the JSON editor — the text on screen would otherwise be + // submitted as an absent argument. + const input = screen.getByLabelText(/Divisor/) as HTMLInputElement; + await user.type(input, "90071992547409910"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + expect(screen.getByText(/this field will be omitted/)).toBeInTheDocument(); + + await user.clear(input); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + expect( + screen.queryByText(/this field will be omitted/), + ).not.toBeInTheDocument(); + }); + + it("stays invalid while any one field is invalid", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + first: { type: "array", title: "First" }, + second: { type: "array", title: "Second" }, + }, + }; + renderWithMantine( + <ValidityHarness schema={schema} onValidityChange={onValidityChange} />, + ); + + await user.type(screen.getByLabelText(/First/), "x"); + await user.type(screen.getByLabelText(/Second/), "y"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + // One of the two recovering is not enough. + await user.clear(screen.getByLabelText(/First/)); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + await user.clear(screen.getByLabelText(/Second/)); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("surfaces a nested object's invalid draft through the outer form", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + outer: { + type: "object", + title: "Outer", + properties: { + config: { type: "array", title: "Config" }, + }, + }, + }, + }; + renderWithMantine( + <ValidityHarness schema={schema} onValidityChange={onValidityChange} />, + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + }); + + it("clears a stale invalid draft when the form moves to another entity", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const { rerender } = renderWithMantine( + <ValidityHarness + schema={jsonSchema} + onValidityChange={onValidityChange} + resetKey="first_tool" + />, + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + // Switching entities remounts the field, discarding its draft — so text + // typed for the previous one must not keep the new one blocked. + rerender( + <ValidityHarness + schema={jsonSchema} + onValidityChange={onValidityChange} + resetKey="second_tool" + />, + ); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("reports valid when the form unmounts holding an invalid draft", async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const { unmount } = renderWithMantine( + <ValidityHarness + schema={jsonSchema} + onValidityChange={onValidityChange} + />, + ); + + await user.type(screen.getByLabelText(/Config/), "x"); + expect(onValidityChange).toHaveBeenLastCalledWith(true); + + unmount(); + expect(onValidityChange).toHaveBeenLastCalledWith(false); + }); + + it("renders without a validity callback", async () => { + const user = userEvent.setup(); + renderWithMantine( + <SchemaForm schema={jsonSchema} values={{}} onChange={vi.fn()} />, + ); + await user.type(screen.getByLabelText(/Config/), "x"); + expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 89a88c23..54e50b6e 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -8,7 +8,7 @@ import { Text, TextInput, } from "@mantine/core"; -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; import { useValueChange } from "../../../hooks/useValueChange"; import type { @@ -169,13 +169,47 @@ function isSameJson(a: unknown, b: unknown): boolean { return serializeJson(a) === serializeJson(b); } +/** + * How a field tells the enclosing `SchemaForm` whether the text it is holding + * can be sent. Keyed by field name rather than closed over per field so the + * reporter is referentially stable, which is what keeps the effect below from + * re-subscribing on every render. + */ +type DraftValidityReporter = (fieldName: string, isValid: boolean) => void; + +/** + * Publish a field's draft validity to the enclosing `SchemaForm`. + * + * Reported from an effect rather than from the change handler because validity + * also moves when the *parent* replaces the value — a cleared form or a loaded + * example re-syncs the draft and makes invalid text valid again without the + * user touching the field. Reporting during that render instead would mean + * updating another component mid-render, which React rejects. + * + * The cleanup reports the field as valid again, so a field that goes away — + * unmounted with the panel, or remounted under a new `resetKey` when the form + * moves to another entity — cannot leave a stale draft blocking submission. + */ +function useDraftValidity( + fieldName: string, + isValid: boolean, + onValidityChange: DraftValidityReporter, +): void { + useEffect(() => { + onValidityChange(fieldName, isValid); + return () => onValidityChange(fieldName, true); + }, [fieldName, isValid, onValidityChange]); +} + interface SchemaJsonFieldProps { + fieldName: string; label: string; description?: string; withAsterisk: boolean; disabled: boolean; value: unknown; onChange: (value: unknown) => void; + onValidityChange: DraftValidityReporter; } /** @@ -200,18 +234,18 @@ interface SchemaJsonFieldProps { * it cannot send, and `undefined` is how "no value supplied" is spelled * everywhere else in this form. * - * That leaves invalid text and an empty field indistinguishable *to the - * parent*, so the field says so itself via `error`. A **required** field is - * already gated — its value is `undefined`, which `hasMissingRequiredFields` - * treats as missing — but an **optional** one would otherwise be dropped from - * the submission with no indication. Disabling Execute/Open/Submit on an - * invalid optional draft needs a validity channel out of this component and - * into all four callers, which is a wider change than this fix; tracked - * separately. + * That leaves invalid text and an empty field indistinguishable *to the value*, + * so the field reports the difference two other ways: it says so on itself via + * `error`, and it reports draft validity up through `onValidityChange` so the + * enclosing form's caller can disable Execute/Open App/Submit (#2020). Without + * the second channel an **optional** field's invalid text is simply dropped + * from a submission the user was allowed to make. */ function SchemaJsonField({ + fieldName, value, onChange, + onValidityChange, ...inputProps }: SchemaJsonFieldProps) { const [draft, setDraft] = useState(() => @@ -230,11 +264,11 @@ function SchemaJsonField({ // Text that is present but does not parse yields no value, so the field would // otherwise submit as absent while the user is still looking at what they - // typed. Saying so on the field is what keeps that from being silent; see the - // note on `SchemaJsonField` for the submit-gating half, which is not local to - // this component. + // typed. Saying so on the field keeps that from being silent; reporting it + // upward is what keeps it from being submittable. const hasInvalidDraft = draft.trim() !== "" && parseJsonDraft(draft) === undefined; + useDraftValidity(fieldName, !hasInvalidDraft, onValidityChange); return ( <SchemaJsonInput @@ -253,7 +287,24 @@ function SchemaJsonField({ ); } +/** + * Whether a number field's draft is text that cannot be sent. + * + * Anything non-empty that `toNumericValue` declines is invalid: a half-typed + * `"-"`, and — the case that matters — an integer past `MAX_SAFE_INTEGER`, + * which is deliberately dropped rather than silently rounded (see + * `toNumericValue`). Both would otherwise submit as an absent argument while + * the text sits in the box. + */ +function hasInvalidNumericDraft(draft: string | number): boolean { + if (typeof draft === "number") { + return false; + } + return draft.trim() !== "" && toNumericValue(draft) === undefined; +} + interface SchemaNumberInputProps { + fieldName: string; label: string; description?: string; withAsterisk: boolean; @@ -263,6 +314,7 @@ interface SchemaNumberInputProps { max?: number; allowDecimal: boolean; onChange: (value: number | undefined) => void; + onValidityChange: DraftValidityReporter; } /** @@ -289,8 +341,10 @@ interface SchemaNumberInputProps { * prop for the case it covers. */ function SchemaNumberInput({ + fieldName, value, onChange, + onValidityChange, ...inputProps }: SchemaNumberInputProps) { const [draft, setDraft] = useState<string | number>(value ?? ""); @@ -301,10 +355,19 @@ function SchemaNumberInput({ } }); + // Same split as the JSON field: text this client cannot send reports no value, + // so it has to say so on the field and report it upward, or the argument is + // dropped from a submission the user was allowed to make (#2020). + const invalidDraft = hasInvalidNumericDraft(draft); + useDraftValidity(fieldName, !invalidDraft, onValidityChange); + return ( <NumberInput {...inputProps} value={draft} + error={ + invalidDraft ? "Not a number — this field will be omitted" : undefined + } onChange={(next) => { setDraft(next); onChange(toNumericValue(next)); @@ -337,6 +400,21 @@ export interface SchemaFormProps { * substitute — callers rebuild it every render, so its identity is unstable. */ resetKey?: string; + /** + * Called with `true` while any field is holding text that cannot be sent — + * unparseable JSON, or a number this client cannot represent exactly. Both + * report their value as `undefined`, which is indistinguishable from an empty + * field once it reaches `values`, so a caller cannot derive this from the + * values it already has (#2020). + * + * Gate the submit action on it: `hasMissingRequiredFields` covers a field with + * *no* value, and this covers a field whose visible text produced none. An + * empty optional field is unaffected — it stays valid and submittable. + * + * The callback is read through a ref, so an inline closure is fine — it is + * called when the answer changes, not on every render. + */ + onValidityChange?: (hasInvalidDraft: boolean) => void; } function getDefaultValue(fieldSchema: InspectorFormSchema): unknown { @@ -362,10 +440,59 @@ export function SchemaForm({ onChange, disabled = false, resetKey, + onValidityChange, }: SchemaFormProps) { const properties = schema.properties ?? {}; const requiredFields = schema.required ?? []; + // The names of fields currently holding unsendable text. Held here rather + // than in each field because only the form sees them all, and only the form + // knows when the last one cleared. + const [invalidFields, setInvalidFields] = useState<ReadonlySet<string>>( + () => new Set(), + ); + + // Stable so a field's reporting effect subscribes once, not per render. The + // updater returns the previous set unchanged when nothing moved, which is + // what makes a nested form's inline callback safe to call repeatedly. + const reportFieldValidity = useCallback<DraftValidityReporter>( + (fieldName, isValid) => { + setInvalidFields((previous) => { + if (previous.has(fieldName) === !isValid) { + return previous; + } + const next = new Set(previous); + if (isValid) { + next.delete(fieldName); + } else { + next.add(fieldName); + } + return next; + }); + }, + [], + ); + + // Read through a ref so the callback's identity is not a dependency. It has + // to be one or the other, and a *stable* dependency is what this needs: a + // nested form is handed a fresh closure every render, and re-running the + // effect for that alone would toggle the parent's state (cleanup, then + // re-report), re-render this form, and loop forever. + const notifyValidity = useRef(onValidityChange); + useEffect(() => { + notifyValidity.current = onValidityChange; + }); + + // The cleanup mirrors the one in `useDraftValidity`: a form that is no longer + // rendered holds no drafts, so an unmounted nested form must not leave the + // outer one blocked. It runs in the same commit as the re-report, so the + // transient `false` is never rendered. + const hasInvalidDraft = invalidFields.size > 0; + useEffect(() => { + notifyValidity.current?.(hasInvalidDraft); + return () => notifyValidity.current?.(false); + }, [hasInvalidDraft]); + function handleFieldChange(fieldName: string, fieldValue: unknown) { onChange({ ...values, [fieldName]: fieldValue }); } @@ -452,6 +579,7 @@ export function SchemaForm({ // The only field holding local state, so the only one that has to be // remounted when `resetKey` says the form moved to another entity. key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} + fieldName={fieldName} label={label} description={description} withAsterisk={isRequired} @@ -463,6 +591,7 @@ export function SchemaForm({ // accepting a value the schema forbids. allowDecimal={fieldSchema.type === "number"} onChange={(val) => handleFieldChange(fieldName, val)} + onValidityChange={reportFieldValidity} /> ); } @@ -541,6 +670,11 @@ export function SchemaForm({ disabled={disabled} // Sub-fields belong to the same entity, so they reset with it. resetKey={resetKey} + // A nested form's invalid draft is the outer form's invalid draft, + // so it reports through the same channel under this field's name. + onValidityChange={(nestedInvalid) => + reportFieldValidity(fieldName, !nestedInvalid) + } /> </IndentedStack> </Stack> @@ -553,12 +687,14 @@ export function SchemaForm({ // Holds local draft state, so — like the number field — it has to be // remounted when `resetKey` says the form moved to another entity. key={resetKey === undefined ? fieldName : `${resetKey}:${fieldName}`} + fieldName={fieldName} label={label} description={description} withAsterisk={isRequired} disabled={disabled} value={rawValue} onChange={(val) => handleFieldChange(fieldName, val)} + onValidityChange={reportFieldValidity} /> ); } diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx index feb434f2..cdad33ef 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx @@ -218,7 +218,13 @@ describe("ToolDetailPanel", () => { }); it("renders the Execute Tool button enabled when not executing", () => { - renderWithMantine(<ToolDetailPanel {...baseProps} tool={simpleTool} />); + renderWithMantine( + <ToolDetailPanel + {...baseProps} + tool={simpleTool} + formValues={{ message: "hi" }} + />, + ); const button = screen.getByRole("button", { name: "Execute Tool" }); expect(button).toBeInTheDocument(); expect(button).not.toBeDisabled(); @@ -231,6 +237,7 @@ describe("ToolDetailPanel", () => { <ToolDetailPanel {...baseProps} tool={simpleTool} + formValues={{ message: "hi" }} onExecute={onExecute} />, ); @@ -502,4 +509,61 @@ describe("ToolDetailPanel", () => { ).not.toBeInTheDocument(); }); }); + + // #2020: Execute used to be gated on `isExecuting` alone — this panel never + // called `hasMissingRequiredFields`, unlike the App and elicitation panels — + // so arguments that could not be sent were executed anyway and simply arrived + // absent at the server. + describe("submit gating (#2020)", () => { + const optionalJsonTool: Tool = { + name: "record_shipment", + inputSchema: { + type: "object", + // An array of no declared item type renders through the JSON editor, + // which is where a draft can fail to parse. + properties: { payload: { type: "array", title: "Payload" } }, + }, + }; + + it("disables Execute while a required argument has no value", () => { + renderWithMantine( + <ToolDetailPanel {...baseProps} tool={simpleTool} formValues={{}} />, + ); + expect( + screen.getByRole("button", { name: "Execute Tool" }), + ).toBeDisabled(); + }); + + it("enables Execute once the required argument is filled", () => { + renderWithMantine( + <ToolDetailPanel + {...baseProps} + tool={simpleTool} + formValues={{ message: "hi" }} + />, + ); + expect( + screen.getByRole("button", { name: "Execute Tool" }), + ).not.toBeDisabled(); + }); + + it("disables Execute while an optional field holds text it cannot send", async () => { + const user = userEvent.setup(); + renderWithMantine( + <ToolDetailPanel {...baseProps} tool={optionalJsonTool} />, + ); + const execute = screen.getByRole("button", { name: "Execute Tool" }); + expect(execute).not.toBeDisabled(); + + // The draft never reaches `formValues` — the field reports `undefined` + // for text it cannot parse — so only the form's validity channel makes + // this visible to the gate. + const jsonInput = screen.getByLabelText(/Payload/); + await user.type(jsonInput, "x"); + expect(execute).toBeDisabled(); + + await user.clear(jsonInput); + expect(execute).not.toBeDisabled(); + }); + }); }); diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index fbb6d26d..46b92cfa 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -19,7 +19,10 @@ import type { ToolAnnotations, } from "@modelcontextprotocol/client"; import { resolveDisplayLabel } from "../../../utils/toolUtils"; -import { toFormSchema } from "../../../utils/jsonUtils"; +import { + hasMissingRequiredFields, + toFormSchema, +} from "../../../utils/jsonUtils"; import { getMirroredHeaderParams } from "@inspector/core/json/xMcpHeader.js"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { ProgressDisplay } from "../../elements/ProgressDisplay/ProgressDisplay"; @@ -213,6 +216,10 @@ export function ToolDetailPanel({ // a prior tool's hidden state doesn't carry over — mirrors how ToolsScreen // clears formValues on change. const [descriptionOpen, setDescriptionOpen] = useState(true); + // Text a field is holding that it could not turn into a value — unparseable + // JSON, or a number this client cannot send exactly. Reported by the form + // because the draft lives inside the field and never reaches `formValues`. + const [hasInvalidDraft, setHasInvalidDraft] = useState(false); const [prevToolName, setPrevToolName] = useState(name); if (name !== prevToolName) { setPrevToolName(name); @@ -245,6 +252,15 @@ export function ToolDetailPanel({ (taskSupport === "required" || ((taskSupport === "optional" || modernTasks) && runAsTask)); + // Two distinct reasons the arguments aren't sendable, and this panel used to + // check neither — Execute was gated on `isExecuting` alone, so a required + // argument left empty and a field full of unparseable text were both + // executable, and simply arrived at the server absent (#2020). + const executeDisabled = + isExecuting || + hasMissingRequiredFields(formSchema, formValues) || + hasInvalidDraft; + return ( <PanelStack> <PinnedHeader> @@ -321,6 +337,7 @@ export function ToolDetailPanel({ // so the form needs the tool name to drop another tool's // in-progress field text. See SchemaFormProps.resetKey. resetKey={name} + onValidityChange={setHasInvalidDraft} /> {progress && <ProgressDisplay params={progress} />} @@ -341,7 +358,7 @@ export function ToolDetailPanel({ <Button size="md" onClick={() => onExecute(effectiveRunAsTask)} - disabled={isExecuting} + disabled={executeDisabled} loading={isExecuting} > Execute Tool From 55734f37764420c01cbd17fd2fb7f5e951d6d953 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:28:59 -0400 Subject: [PATCH 156/941] fix: accessible names for the authorization-parameter rows (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 2 (suppressed comments). Five of the seven findings were the accessibility of the new row list and one stray cast; all are addressed here. - The reserved-key reason is now the key input's `error` **string** rather than a bare boolean plus free-standing text. Mantine renders it in the input's own error slot and wires the `aria-describedby` association, so a screen reader learns *why* the field is invalid; a boolean only set `aria-invalid`. This also deletes the duplicate `ReservedParamError` text and its Stack wrapper. - Each control carries a per-row `aria-label` — the section heading and the placeholders are not programmatically associated with the inputs, so the key box, the value box, and a remove button announcing only "X" were indistinguishable to assistive technology. The label names the row by its key, falling back to "row N" while the key is still blank. - Dropped an unnecessary `as unknown as Record<string, unknown>` in the servers-route integration test: `MCPConfig.mcpServers` is already typed as `Record<string, StoredMCPServer>`, so the entry's `oauth` reads directly. Three tests cover the new behavior (accessible names, the blank-key row-number fallback, and the aria-describedby association), each verified to fail against the pre-fix component. No change to the reserved-key rule or the EMA boundary. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ServerSettingsForm.test.tsx | 77 +++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 46 ++++++----- .../mcp/remote/servers-route.test.ts | 5 +- 3 files changed, 105 insertions(+), 23 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index b66d76ac..a9ff4f5d 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -826,6 +826,83 @@ describe("ServerSettingsForm", () => { ); }); + // #2018 — the section heading and the placeholders are not programmatically + // associated with these inputs, so each control carries its own aria-label and + // the reserved-key reason is the input's `error` string (which Mantine wires + // to the input via aria-describedby) rather than free-standing text. + it("gives each authorization-parameter control an accessible name", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }], + }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByRole("textbox", { + name: "Authorization parameter name, kc_idp_hint", + }), + ).toHaveValue("kc_idp_hint"); + expect( + screen.getByRole("textbox", { + name: "Authorization parameter value, kc_idp_hint", + }), + ).toHaveValue("corp"); + expect( + screen.getByRole("button", { + name: "Remove authorization parameter kc_idp_hint", + }), + ).toBeInTheDocument(); + }); + + it("falls back to a row number in the accessible name when the key is blank", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "", value: "" }], + }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByRole("textbox", { + name: "Authorization parameter name, row 1", + }), + ).toBeInTheDocument(); + }); + + // The reason must reach assistive tech through the input, not just as text on + // the page: Mantine emits the `error` string with an id the input references. + it("associates the reserved-key reason with the key input", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationParams: [{ key: "state", value: "spoofed" }], + }} + expandedSections={["oauth"]} + />, + ); + const keyInput = screen.getByRole("textbox", { + name: "Authorization parameter name, state", + }); + const describedBy = keyInput.getAttribute("aria-describedby"); + expect(describedBy).toBeTruthy(); + const reason = describedBy + ?.split(" ") + .map((id) => document.getElementById(id)?.textContent ?? "") + .join(" "); + expect(reason).toContain( + '"state" is set by the authorization flow and cannot be overridden.', + ); + }); + it("does not flag a blank authorization-parameter row as reserved", () => { renderWithMantine( <ServerSettingsForm diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 6a3bee1c..138003a4 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -202,11 +202,6 @@ const FieldDescription = Text.withProps({ c: "dimmed", }); -const ReservedParamError = Text.withProps({ - size: "xs", - c: "var(--inspector-danger-text)", -}); - const ClearStoredOAuthButton = Button.withProps({ variant: "light", color: "red", @@ -268,9 +263,17 @@ function KeyValueRows({ ); } -// Reserved-key rejection is inline per row (#2018): the key input goes red with -// the reason under it, and the section-level warning below repeats it so the -// rejection is visible even when the offending row is scrolled out of view. +// Reserved-key rejection is inline per row (#2018): the reason is passed as the +// key input's `error` **string**, so Mantine renders it in the input's own error +// slot and wires the `aria-describedby` association — a bare boolean would mark +// the field invalid without telling a screen reader why. The section-level Alert +// below repeats it so the rejection stays visible when the offending row is +// scrolled out of view. +// +// Each control carries a per-row `aria-label`: the section heading and the +// placeholders are not programmatically associated with the inputs, so without +// one an assistive technology cannot tell the key box from the value box, and +// the remove button announces only "X". function AuthorizationParamRows({ params, onChange, @@ -282,13 +285,15 @@ function AuthorizationParamRows({ }) { return ( <> - {params.map((param, index) => ( - <Stack key={index} gap={4}> - <Group grow> + {params.map((param, index) => { + const rowLabel = param.key.trim() || `row ${index + 1}`; + return ( + <Group key={index} grow align="flex-start"> <ClearableTextInput + aria-label={`Authorization parameter name, ${rowLabel}`} placeholder="Parameter (e.g. kc_idp_hint)" value={param.key} - error={authorizationParamKeyError(param.key) !== undefined} + error={authorizationParamKeyError(param.key)} onChange={(e) => onChange(index, e.currentTarget.value, param.value) } @@ -301,6 +306,7 @@ function AuthorizationParamRows({ } /> <ClearableTextInput + aria-label={`Authorization parameter value, ${rowLabel}`} placeholder="Value" value={param.value} onChange={(e) => @@ -312,15 +318,15 @@ function AuthorizationParamRows({ ) : null } /> - <RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon> + <RemoveIcon + aria-label={`Remove authorization parameter ${rowLabel}`} + onClick={() => onRemove(index)} + > + X + </RemoveIcon> </Group> - {authorizationParamKeyError(param.key) ? ( - <ReservedParamError> - {authorizationParamKeyError(param.key)} - </ReservedParamError> - ) : null} - </Stack> - ))} + ); + })} </> ); } diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 9e0f8a5a..18803b5a 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -471,9 +471,8 @@ describe("/api/servers routes", () => { }), }); expect(res.status).toBe(200); - const stored = readConfig(h.configPath).mcpServers - .authparams as unknown as Record<string, unknown>; - expect(stored.oauth).toEqual({ + const stored = readConfig(h.configPath).mcpServers.authparams; + expect(stored?.oauth).toEqual({ authorizationParams: { kc_idp_hint: "corp" }, }); }); From f36278538e5fdc638eb603750e4b3358c8cc9d17 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:42:32 -0400 Subject: [PATCH 157/941] fix: extract the param row constant and cover the manager bridge (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 3 (both findings suppressed; both valid). - The a11y fix in the previous commit added `align="flex-start"` to the row's `Group`, giving it two static layout props and so tripping the .withProps() rule in AGENTS.md — which applies to a single-use element too. Extracted as `AuthorizationParamRow`. (The sibling `KeyValueRows` Group carries one static prop and stays inline, correctly.) - Added OAuthManager coverage for the config→provider bridge. The point is sharp: the provider test proves the merge and the runner test proves the options object, but nothing crossed the seam in `createOAuthProvider`, so deleting the `authorizationParams:` line left the feature dead with every test green. Verified by doing exactly that — both new cases now fail with `expected null to be 'corp-idp'` — then restoring it. The second case also pins the reserved-key drop through the manager path. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ServerSettingsForm/ServerSettingsForm.tsx | 11 ++++- .../src/test/core/mcp/oauthManager.test.ts | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 138003a4..e6fd2ac3 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -202,6 +202,13 @@ const FieldDescription = Text.withProps({ c: "dimmed", }); +// `align="flex-start"` keeps the key/value/remove controls top-aligned when the +// key input grows an error message, so the row doesn't jump as it appears. +const AuthorizationParamRow = Group.withProps({ + grow: true, + align: "flex-start", +}); + const ClearStoredOAuthButton = Button.withProps({ variant: "light", color: "red", @@ -288,7 +295,7 @@ function AuthorizationParamRows({ {params.map((param, index) => { const rowLabel = param.key.trim() || `row ${index + 1}`; return ( - <Group key={index} grow align="flex-start"> + <AuthorizationParamRow key={index}> <ClearableTextInput aria-label={`Authorization parameter name, ${rowLabel}`} placeholder="Parameter (e.g. kc_idp_hint)" @@ -324,7 +331,7 @@ function AuthorizationParamRows({ > X </RemoveIcon> - </Group> + </AuthorizationParamRow> ); })} </> diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 36e566a1..99af81bf 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1574,6 +1574,53 @@ describe("OAuthManager", () => { const provider = await manager.createOAuthProviderForTransport(); expect(provider.constructor.name).toBe("EmaTransportOAuthProvider"); }); + + // #2018 — the manager→provider bridge for the custom authorization + // parameters. The provider test proves the merge and the runner test proves + // the options object, but neither crosses this seam: without this case, + // deleting `authorizationParams:` from `createOAuthProvider` would leave the + // feature dead with every other test still green. + it("forwards configured authorizationParams to the provider it builds", async () => { + const params = createMockParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ + authorizationParams: { kc_idp_hint: "corp-idp", prompt: "login" }, + }); + + const provider = await manager.createOAuthProviderForTransport(); + provider.redirectToAuthorization( + new URL("https://as.example.com/authorize?client_id=abc&state=xyz"), + ); + + const navigate = params.initialConfig.navigation + ?.navigateToAuthorization as ReturnType<typeof vi.fn>; + const navigated = navigate.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(navigated.searchParams.get("prompt")).toBe("login"); + // The flow's own parameters are untouched. + expect(navigated.searchParams.get("state")).toBe("xyz"); + }); + + it("drops a reserved key configured on the manager", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const params = createMockParams(); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ + authorizationParams: { state: "spoofed", kc_idp_hint: "corp-idp" }, + }); + + const provider = await manager.createOAuthProviderForTransport(); + provider.redirectToAuthorization( + new URL("https://as.example.com/authorize?client_id=abc&state=xyz"), + ); + + const navigate = params.initialConfig.navigation + ?.navigateToAuthorization as ReturnType<typeof vi.fn>; + const navigated = navigate.mock.calls[0]?.[0] as URL; + expect(navigated.searchParams.get("state")).toBe("xyz"); + expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp"); + expect(warn).toHaveBeenCalled(); + }); }); describe("getOAuthState (enterprise managed / scope)", () => { From d95a6e629c8fe89c2df4ef3bc952468e722ef363 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 21:54:16 -0400 Subject: [PATCH 158/941] fix: stop dropping oauth.onInsufficientScope when extracting the client secret (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 4 (one suppressed finding, and a real bug). `extractSecretsFromStored` rebuilt the stripped `oauth` object from an allow-list of the non-secret fields. That list had gone stale: it never named `onInsufficientScope`, so a server carrying both a client secret and a non-default SEP-2350 policy silently lost the policy on save, falling back to `reauthorize`. This predates #2018 — the PR only extended the same list with `authorizationParams` — but the list is the defect, so fix the class rather than add a third entry to it. The stripped entry now subtracts the one secret instead of enumerating what to keep, so any field added to `oauth` later is preserved by construction. Regression test verified to fail against the allow-list version: `expected { clientId: 'cid' } to deeply equal { clientId: 'cid', onInsufficientScope: 'throw' }`. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/serverList.test.ts | 21 ++++++++++++++++ core/mcp/serverList.ts | 24 ++++++------------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 824419b5..c6dc9436 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -925,6 +925,27 @@ describe("extractSecretsFromStored", () => { expect(stripped.oauth).toEqual({ enterpriseManaged: true }); }); + // The stripped entry now subtracts the secret instead of enumerating the + // fields to keep, so a field added to `oauth` later survives by construction. + // `onInsufficientScope` is the case the old allow-list had already lost. + it("preserves oauth.onInsufficientScope when lifting clientSecret to keychain", () => { + const stored: StoredMCPServer = { + type: "streamable-http", + url: "https://x.test", + oauth: { + clientId: "cid", + clientSecret: "shh", + onInsufficientScope: "throw", + }, + }; + const { stripped, secrets } = extractSecretsFromStored(stored); + expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" }); + expect(stripped.oauth).toEqual({ + clientId: "cid", + onInsufficientScope: "throw", + }); + }); + it("preserves oauth.authorizationParams when lifting clientSecret to keychain", () => { const stored: StoredMCPServer = { type: "streamable-http", diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 0c3c759e..c6e5e507 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -642,23 +642,13 @@ export function extractSecretsFromStored( if (stored.oauth?.clientSecret) { secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret; - const restOauth: { - clientId?: string; - scopes?: string; - authorizationParams?: Record<string, string>; - enterpriseManaged?: boolean; - } = {}; - if (stored.oauth.clientId !== undefined) - restOauth.clientId = stored.oauth.clientId; - if (stored.oauth.scopes !== undefined) - restOauth.scopes = stored.oauth.scopes; - // Not a secret — the authorization parameters stay on the stripped entry so - // stripping the client secret doesn't silently discard them. (#2018) - if (stored.oauth.authorizationParams !== undefined) - restOauth.authorizationParams = stored.oauth.authorizationParams; - if (stored.oauth.enterpriseManaged === true) { - restOauth.enterpriseManaged = true; - } + // Keep every OAuth field except the one secret, rather than enumerating the + // non-secret ones. An allow-list here is a standing trap: it silently drops + // any field added to `oauth` afterwards, and it had already done so — + // `onInsufficientScope` was lost whenever a server carried both a client + // secret and a non-default SEP-2350 policy. Subtracting the secret instead + // means a future field is preserved by construction. (#2018) + const { clientSecret: _clientSecret, ...restOauth } = stored.oauth; if (Object.keys(restOauth).length > 0) { stripped.oauth = restOauth; } else { From e6e4600a731b4a74d92901f1c19a15cfc5400850 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 22:10:36 -0400 Subject: [PATCH 159/941] fix: validate authorizationParams off disk; note the EMA exception (#2018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 5 — three of four findings implemented. - `cleanAuthorizationParams` validates the field as it comes off disk. Only the web client's /api/servers route checked the shape; the CLI and TUI read mcp.json directly, so `authorizationParams: "oops"` enumerated as the character-indexed pairs 0=o, 1=o, … and `{ audience: 5 }` reached URLSearchParams.set, which stringifies it — either way the Inspector sent query parameters the user never wrote. Now validated in shared code so all three clients enforce the documented string→string contract, warning on each rejection. Follows `cleanRoots`, which guards the same class for `roots`. - The form's description now states that these parameters are not sent while Enterprise-managed authorization is on. The EMA leg authorizes against the enterprise IdP — a different authorization server — and deliberately sends none of them, so the copy was presenting a configured-but-inert value as active. - The /api/servers malformed-`oauth` warning listed an expected shape omitting `onInsufficientScope`, which the validator accepts and the docs describe, so it told users a valid config was malformed. Ten new cases, all verified to fail against the pre-fix sources. Declined: recording the authorize URL in the Network tab (repeat of round 2) — that URL is reached by a top-level browser navigation rather than a tracked fetch, so it has never appeared there for any authorization request; adding a synthetic entry changes every OAuth flow and is a product decision for a maintainer, not a change to make inside this PR. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ServerSettingsForm.test.tsx | 30 ++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 3 + .../web/src/test/core/mcp/serverList.test.ts | 92 +++++++++++++++++++ core/mcp/remote/node/server.ts | 2 +- core/mcp/serverList.ts | 55 +++++++++-- 5 files changed, 173 insertions(+), 9 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index a9ff4f5d..d238228a 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -903,6 +903,36 @@ describe("ServerSettingsForm", () => { ); }); + // #2018 — the EMA leg authorizes against the enterprise IdP, a different + // authorization server, and deliberately sends none of these parameters. The + // description has to say so, or the form shows a configured value as active + // when it will not be sent. + it("warns that authorization parameters are unused under enterprise-managed auth", () => { + const { rerender } = renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ ...emptySettings, enterpriseManaged: true }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByText(/Not sent while Enterprise-managed authorization is on/), + ).toBeInTheDocument(); + + rerender( + <ServerSettingsForm + {...baseHandlers} + settings={{ ...emptySettings, enterpriseManaged: false }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.queryByText( + /Not sent while Enterprise-managed authorization is on/, + ), + ).not.toBeInTheDocument(); + }); + it("does not flag a blank authorization-parameter row as reserved", () => { renderWithMantine( <ServerSettingsForm diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index e6fd2ac3..276c9cf9 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -835,6 +835,9 @@ export function ServerSettingsForm({ only — never the token request. Use them for provider-specific hints such as kc_idp_hint, login_hint, prompt, acr_values, or audience. + {settings.enterpriseManaged + ? " Not sent while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server." + : ""} </FieldDescription> {authorizationParams.length === 0 ? ( <EmptyHint>No additional parameters configured</EmptyHint> diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index c6dc9436..6641e55b 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { + cleanAuthorizationParams, cleanRoots, DEFAULT_SEED_CONFIG, envPairsToRecord, @@ -1415,3 +1416,94 @@ describe("stdio env / cwd mirroring", () => { }); }); }); + +// #2018 — `StoredMCPServer.oauth.authorizationParams` is typed +// `Record<string, string>`, but only the web client's `/api/servers` route +// validates it; the CLI and TUI read `mcp.json` off disk. These cases drive the +// shapes a hand-edited file can hold, which the type says are impossible. +describe("cleanAuthorizationParams", () => { + let warnSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("passes a well-formed record through", () => { + expect( + cleanAuthorizationParams({ kc_idp_hint: "corp", prompt: "login" }), + ).toEqual({ kc_idp_hint: "corp", prompt: "login" }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("returns undefined for an absent or empty record", () => { + expect(cleanAuthorizationParams(undefined)).toBeUndefined(); + expect(cleanAuthorizationParams({})).toBeUndefined(); + }); + + // A bare string enumerates as character-indexed pairs (0=o, 1=o, …), so + // without this guard the Inspector would send four invented parameters. + it("rejects a string with a warning instead of enumerating its characters", () => { + expect( + cleanAuthorizationParams("oops" as unknown as Record<string, string>), + ).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects an array with a warning", () => { + expect( + cleanAuthorizationParams(["a"] as unknown as Record<string, string>), + ).toBeUndefined(); + expect(warnSpy.mock.calls[0]?.[1]).toBe("array"); + }); + + it("rejects null with a warning", () => { + expect( + cleanAuthorizationParams(null as unknown as Record<string, string>), + ).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + // `URLSearchParams.set` stringifies whatever it is given, so a number would + // otherwise reach the authorize URL as "5". + it("drops a non-string value with a warning and keeps the rest", () => { + expect( + cleanAuthorizationParams({ + audience: 5, + kc_idp_hint: "corp", + } as unknown as Record<string, string>), + ).toEqual({ kc_idp_hint: "corp" }); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when every value was dropped", () => { + expect( + cleanAuthorizationParams({ audience: 5 } as unknown as Record< + string, + string + >), + ).toBeUndefined(); + }); + + it("skips a blank key", () => { + expect(cleanAuthorizationParams({ " ": "x", ok: "y" })).toEqual({ + ok: "y", + }); + }); + + it("is applied when lifting stored fields into settings", () => { + const settings = storedFieldsToInspectorSettings({ + type: "streamable-http", + url: "https://x.test", + oauth: { + authorizationParams: { audience: 5, kc_idp_hint: "corp" }, + }, + } as unknown as StoredMCPServer); + expect(settings?.oauthAuthorizationParams).toEqual([ + { key: "kc_idp_hint", value: "corp" }, + ]); + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 2489a521..d0ee6ea1 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1255,7 +1255,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged?, onInsufficientScope? }`.", ); delete valObj.oauth; } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index c6e5e507..1f34cab0 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -194,6 +194,45 @@ export function envPairsToRecord( return out; } +/** + * Validate a server's `oauth.authorizationParams` as it comes off disk, or + * `undefined` when there is nothing usable. (#2018) + * + * `StoredMCPServer` types this as `Record<string, string>`, but that is a + * compile-time promise a hand-edited `mcp.json` does not keep — and only the web + * client's `/api/servers` route checks it, while the CLI and TUI read the file + * directly. Without this, `authorizationParams: "oops"` enumerates as the + * character-indexed pairs `0=o, 1=o, …` and `{ audience: 5 }` reaches + * `URLSearchParams.set`, which stringifies it — either way the Inspector sends + * query parameters the user never wrote. Each rejection warns, following + * `cleanRoots` above, which guards the same class for `roots`. + */ +export function cleanAuthorizationParams( + params: Record<string, string> | undefined, +): Record<string, string> | undefined { + if (params === undefined) return undefined; + // Keep: unreachable per the parameter type, reachable from disk. + if (typeof params !== "object" || params === null || Array.isArray(params)) { + console.warn( + "Ignoring `oauth.authorizationParams`: expected an object of string values, got", + Array.isArray(params) ? "array" : typeof params, + ); + return undefined; + } + const out: Record<string, string> = {}; + for (const [key, value] of Object.entries(params)) { + if (typeof value !== "string") { + console.warn( + `Dropping \`oauth.authorizationParams.${key}\`: expected a string value, got ${typeof value}.`, + ); + continue; + } + if (key.trim() === "") continue; + out[key] = value; + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** * Collapse a server's custom authorization-parameter rows into the record the * `InspectorClientOptions.oauth.authorizationParams` option takes, or @@ -309,14 +348,14 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes; // Optional record with no default; carried through only when the file has a // non-empty object, so an absent/empty field reads back as unset and the - // write side then omits it — keeping a byte-stable round-trip. (#2018) - if ( - stored.oauth?.authorizationParams && - Object.keys(stored.oauth.authorizationParams).length > 0 - ) { - settings.oauthAuthorizationParams = envRecordToPairs( - stored.oauth.authorizationParams, - ); + // write side then omits it — keeping a byte-stable round-trip. Sanitized + // rather than trusted: only the web client's `/api/servers` route validates + // this field, while the CLI/TUI read `mcp.json` straight off disk. (#2018) + const authorizationParams = cleanAuthorizationParams( + stored.oauth?.authorizationParams, + ); + if (authorizationParams) { + settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams); } if (stored.oauth?.onInsufficientScope) { settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope; From 4676af4e39c585008135007c3a6329f08a774e35 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 23:51:21 -0400 Subject: [PATCH 160/941] test(web): pin the JSON editor's reported escaping reproductions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues report one defect: the schema form's JSON fallback re-escaping the text being typed. #1853 saw it while typing an array, #1856 on the Backspace that first makes a valid array invalid, and #1885 on a nullable parameter whose default `null` had to be edited in place. The mechanism — unparseable draft text stored back as the field's value, which the next controlled render re-JSON.stringify'd — was removed by the draft/value split in SchemaJsonField and the nullable-union collapse (#1928, #2007, #2020). Those fixes were made for differently-framed issues, so nothing pinned the flows these three reports actually describe. Verified against the pre-fix commit: four of the five new tests fail there with the reported corruption, including '"\"\"a"' for the caret-outside-the-quotes case and the compounding backslash cascade for the typed array. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 6c7dd77b..908bc33d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1477,3 +1477,174 @@ describe("SchemaForm draft validity (#2020)", () => { expect(screen.getByText(/Not valid JSON/)).toBeInTheDocument(); }); }); + +// Three separate reports of one defect: the JSON editor re-escaping the text +// being typed. #1853 saw it while typing an array, #1856 on the Backspace that +// first makes a valid array invalid, and #1885 on a nullable parameter whose +// default `null` had to be edited in place. All three come from the same +// mechanism — unparseable draft text stored back as the field's *value*, which +// the next controlled render re-`JSON.stringify`d, adding a layer of quotes and +// backslashes per keystroke. +// +// The mechanism was removed by the draft/value split in `SchemaJsonField` +// (#1928/#2007) and the nullable-union collapse that keeps a `T | null` field +// off this editor entirely (#1928). These lock each report's own reproduction +// to it, since the fixes were made for differently-framed issues and nothing +// otherwise pins the reported flows. +describe("JSON editor escaping (#1853, #1856, #1885)", () => { + function EscapingHarness({ + schema, + initial = {}, + }: { + schema: InspectorFormSchema; + initial?: Record<string, unknown>; + }) { + const [values, setValues] = useState<Record<string, unknown>>(initial); + return <SchemaForm schema={schema} values={values} onChange={setValues} />; + } + + // #1853: `batch_process_items`, typed character by character. + it("types a string array through without escaping it (#1853)", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + itemIds: { + type: "array", + title: "Item Ids", + items: { type: "string" }, + }, + }, + required: ["itemIds"], + }; + renderWithMantine(<EscapingHarness schema={schema} />); + + const jsonInput = screen.getByLabelText(/Item Ids/) as HTMLTextAreaElement; + // `[` opens a userEvent keyboard descriptor, so it is escaped as `[[`. + await user.type(jsonInput, '[["item-1","item-2"]'); + + expect(jsonInput.value).toBe('["item-1","item-2"]'); + expect(jsonInput.value).not.toContain("\\"); + }); + + // #1853 again, from the comment thread: the caret sitting *outside* the + // quotes of a `""` value. One keystroke there made the draft invalid, which + // is all it took — `""` + `a` rendered as `"\"\"a"`. + it("keeps a keystroke typed outside a string's quotes literal (#1853)", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + // No `type`, so the field lands on the JSON editor holding a string. + properties: { note: { title: "Note" } }, + }; + renderWithMantine( + <EscapingHarness schema={schema} initial={{ note: "" }} />, + ); + + const jsonInput = screen.getByLabelText(/Note/) as HTMLTextAreaElement; + await user.click(jsonInput); + await user.keyboard("{End}a"); + + expect(jsonInput.value).toBe('""a'); + }); + + // #1856: `sum_numbers`, Backspace with the caret after the closing `]`. + it("keeps the draft raw when Backspace invalidates an array (#1856)", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + numbers: { + type: "array", + title: "Numbers", + items: { type: "number" }, + }, + }, + required: ["numbers"], + }; + renderWithMantine( + <EscapingHarness schema={schema} initial={{ numbers: [1, 2] }} />, + ); + + const jsonInput = screen.getByLabelText(/Numbers/) as HTMLTextAreaElement; + await user.click(jsonInput); + await user.keyboard("{End}{Backspace}"); + + // Exactly the seeded text minus its last character — the draft the reporter + // expected, where the field instead showed a quoted, escaped string. + expect(jsonInput.value).toBe("[\n 1,\n 2\n"); + + // Each further edit compounded the escaping, so keep going. + await user.keyboard("{Backspace}{Backspace}"); + expect(jsonInput.value).not.toContain("\\"); + expect(jsonInput.value.startsWith('"')).toBe(false); + + // And the draft is still live: closing it back up produces a real array. + await user.keyboard("2]"); + expect(jsonInput.value).toBe("[\n 1,\n 2]"); + }); + + // #1885: FastMCP's `b: int | None = None`, which compiles to an `anyOf` with + // a null branch and `default: null`. The reporter was editing the literal + // `null` token in a raw JSON box; collapsing the union routes the field to a + // real number input, so there is no JSON token to edit in the first place. + it("edits a nullable integer as a number input, not a null token (#1885)", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + a: { type: "integer", title: "A" }, + b: { + title: "B", + anyOf: [{ type: "integer" }, { type: "null" }], + default: null, + }, + }, + required: ["a"], + }; + renderWithMantine( + <EscapingHarness schema={schema} initial={{ b: null }} />, + ); + + const b = screen.getByLabelText(/^B$/) as HTMLInputElement; + expect(b.tagName).toBe("INPUT"); + // `null` is rendered as "no value", not as four editable characters. + expect(b.value).toBe(""); + + await user.click(b); + await user.keyboard("42{Backspace}"); + expect(b.value).toBe("4"); + expect(b.value).not.toContain("\\"); + }); + + // The same nullable-with-`default: null` shape on a field the collapse still + // sends to the JSON editor (an array union). Backspacing the `null` token + // there is the exact keystroke sequence from #1885's recording. + it("backspaces a null default in the JSON editor without escaping (#1885)", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { + cfg: { + title: "Cfg", + anyOf: [{ type: "array" }, { type: "null" }], + default: null, + }, + }, + }; + renderWithMantine( + <EscapingHarness schema={schema} initial={{ cfg: null }} />, + ); + + const jsonInput = screen.getByLabelText(/Cfg/) as HTMLTextAreaElement; + expect(jsonInput.value).toBe("null"); + + await user.click(jsonInput); + await user.keyboard("{End}{Backspace}"); + expect(jsonInput.value).toBe("nul"); + + await user.keyboard("{Backspace}{Backspace}{Backspace}"); + expect(jsonInput.value).toBe(""); + expect(jsonInput.value).not.toContain("\\"); + }); +}); From 325ac536cd1cb6ac43522bce0c062f7c2393eacc Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 15 Aug 2026 23:59:31 -0400 Subject: [PATCH 161/941] test(web): resolve the null defaults from the schema, not a seeded value Copilot review: seeding `{ b: null }` / `{ cfg: null }` meant the schema's `default: null` was never resolved, so a regression that dropped null defaults would have left both #1885 tests green while claiming to pin that path. Render the harness unseeded instead, which is also how the reporter's form arrived. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../components/groups/SchemaForm/SchemaForm.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 908bc33d..9e573a9c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1602,9 +1602,9 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { }, required: ["a"], }; - renderWithMantine( - <EscapingHarness schema={schema} initial={{ b: null }} />, - ); + // No seeded value: the `null` has to come from the schema's `default`, + // which is where the reporter's did. + renderWithMantine(<EscapingHarness schema={schema} />); const b = screen.getByLabelText(/^B$/) as HTMLInputElement; expect(b.tagName).toBe("INPUT"); @@ -1632,9 +1632,9 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { }, }, }; - renderWithMantine( - <EscapingHarness schema={schema} initial={{ cfg: null }} />, - ); + // Again unseeded, so the editor's opening `null` is the schema default + // being resolved — the state the reporter's recording starts from. + renderWithMantine(<EscapingHarness schema={schema} />); const jsonInput = screen.getByLabelText(/Cfg/) as HTMLTextAreaElement; expect(jsonInput.value).toBe("null"); From a24413af6f96eec1a84ff4881ff5721562511e2d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 00:27:56 -0400 Subject: [PATCH 162/941] fix(web): stop a schema default from reverting the edit in progress (#2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field whose schema declares a `default` could not be edited. Unsendable draft text reports `undefined` by design — an inspector must not report a value it cannot send — and `resolveValue` turned that straight back into the default. That is a real change from the previous value, so the draft/value re-sync rewrote the box to the default and the keystroke was lost. Almost every edit passes through an unsendable state, which left a defaulted array or object argument uneditable in the JSON editor, and a defaulted number field unable to be emptied. Hand the two draft-holding fields the raw value and the schema default separately, and compare only against the raw one. The default now seeds what the field opens with, which is what a `default` means, rather than being re-imposed while the user types. Every other field keeps resolving through `resolveValue` unchanged. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 105 ++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 44 +++++++- 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 9e573a9c..89ee31bb 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1648,3 +1648,108 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { expect(jsonInput.value).not.toContain("\\"); }); }); + +// A schema `default` is what a field *opens* with, not a value re-imposed on +// every unsendable draft. Unsendable text reports `undefined` by design, and +// `resolveValue` used to turn that straight back into the default — a real +// change from the previous value, so the draft/value re-sync rewrote the box +// and reverted the keystroke. Almost every edit passes through an unsendable +// state, which left a defaulted array or object argument uneditable. +describe("SchemaForm defaulted fields (#2026)", () => { + function DefaultHarness({ + schema, + initial = {}, + }: { + schema: InspectorFormSchema; + initial?: Record<string, unknown>; + }) { + const [values, setValues] = useState<Record<string, unknown>>(initial); + return <SchemaForm schema={schema} values={values} onChange={setValues} />; + } + + const arrayWithDefault: InspectorFormSchema = { + type: "object", + properties: { tags: { type: "array", title: "Tags", default: ["a"] } }, + }; + + it("opens the JSON editor on the schema default", () => { + renderWithMantine(<DefaultHarness schema={arrayWithDefault} />); + expect((screen.getByLabelText(/Tags/) as HTMLTextAreaElement).value).toBe( + '[\n "a"\n]', + ); + }); + + it("keeps a keystroke typed into a defaulted JSON field", async () => { + const user = userEvent.setup(); + // Seeded the way the Tools tab seeds it — from the schema's defaults, as a + // separate value than the schema's own. Sharing one reference hid this. + renderWithMantine( + <DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />, + ); + + const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement; + await user.click(jsonInput); + await user.keyboard("{End}x"); + + // The invalid draft is the user's, not the default reasserting itself. + expect(jsonInput.value).toBe('[\n "a"\n]x'); + }); + + it("lets a defaulted JSON field be edited to a new value", async () => { + const user = userEvent.setup(); + renderWithMantine( + <DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />, + ); + + const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement; + await user.clear(jsonInput); + await user.type(jsonInput, '[["b"]'); + + expect(jsonInput.value).toBe('["b"]'); + expect(screen.queryByText(/Not valid JSON/)).not.toBeInTheDocument(); + }); + + it("lets a defaulted number field be emptied", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + properties: { n: { type: "integer", title: "N", default: 30 } }, + }; + renderWithMantine(<DefaultHarness schema={schema} initial={{ n: 30 }} />); + + const n = screen.getByLabelText(/N/) as HTMLInputElement; + expect(n.value).toBe("30"); + + await user.click(n); + await user.keyboard("{End}{Backspace}{Backspace}"); + + // The box stays empty instead of refilling itself with the default. + expect(n.value).toBe(""); + }); + + it("still re-syncs a defaulted field when the value changes externally", async () => { + function ExternalHarness() { + const [values, setValues] = useState<Record<string, unknown>>({ + tags: ["a"], + }); + return ( + <> + <SchemaForm + schema={arrayWithDefault} + values={values} + onChange={setValues} + /> + <button type="button" onClick={() => setValues({ tags: ["z"] })}> + load example + </button> + </> + ); + } + const user = userEvent.setup(); + renderWithMantine(<ExternalHarness />); + + const jsonInput = screen.getByLabelText(/Tags/) as HTMLTextAreaElement; + await user.click(screen.getByRole("button", { name: "load example" })); + expect(jsonInput.value).toBe('[\n "z"\n]'); + }); +}); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 54e50b6e..a849ab50 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -207,7 +207,14 @@ interface SchemaJsonFieldProps { description?: string; withAsterisk: boolean; disabled: boolean; + /** + * The field's own entry in the form's values — **not** run through + * `resolveValue`. A draft-holding field is handed the raw value and the + * schema default separately; see `defaultValue` for why. + */ value: unknown; + /** The schema `default`, used to seed the draft only. See `defaultValue`. */ + defaultValue: unknown; onChange: (value: unknown) => void; onValidityChange: DraftValidityReporter; } @@ -244,18 +251,29 @@ interface SchemaJsonFieldProps { function SchemaJsonField({ fieldName, value, + defaultValue, onChange, onValidityChange, ...inputProps }: SchemaJsonFieldProps) { - const [draft, setDraft] = useState(() => - value === undefined ? "" : serializeJson(value), - ); + const [draft, setDraft] = useState(() => { + const initial = value === undefined ? defaultValue : value; + return initial === undefined ? "" : serializeJson(initial); + }); // Re-sync only when the parent's value genuinely diverges from what the draft // parses to, which leaves an external reset (a cleared form, a loaded // example) working while an in-progress `[` — whose parse is `undefined`, // matching the `undefined` we just emitted — is left alone. + // + // The value compared here is the field's *raw* entry, never `resolveValue`'s + // default substitution (#2026). Unsendable text reports `undefined`, and a + // defaulted field resolves that straight back to its default — a real change + // from the previous value, so this would rewrite the draft to the default and + // revert the keystroke. Almost every edit passes through an unsendable state, + // which made a defaulted array or object argument uneditable. The default + // belongs to what the field *opens* with, seeded above, not to what is + // re-imposed while it is being typed into. useValueChange(value, (next) => { if (!isSameJson(parseJsonDraft(draft), next)) { setDraft(next === undefined ? "" : serializeJson(next)); @@ -309,7 +327,10 @@ interface SchemaNumberInputProps { description?: string; withAsterisk: boolean; disabled: boolean; + /** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */ value: number | undefined; + /** The schema `default`, used to seed the draft only (#2026). */ + defaultValue: number | undefined; min?: number; max?: number; allowDecimal: boolean; @@ -343,12 +364,19 @@ interface SchemaNumberInputProps { function SchemaNumberInput({ fieldName, value, + defaultValue, onChange, onValidityChange, ...inputProps }: SchemaNumberInputProps) { - const [draft, setDraft] = useState<string | number>(value ?? ""); + const [draft, setDraft] = useState<string | number>( + value ?? defaultValue ?? "", + ); + // Compared against the raw value, never the default-substituted one, for the + // reason spelled out on `SchemaJsonField` (#2026): clearing this box reports + // no value, which a defaulted field resolved straight back to its default — + // so the box refilled itself and the argument could not be emptied. useValueChange(value, (next) => { if (!Object.is(toNumericValue(draft), next)) { setDraft(next ?? ""); @@ -584,7 +612,9 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - value={rawValue as number | undefined} + // Raw and default kept apart, not pre-resolved (#2026). + value={values[fieldName] as number | undefined} + defaultValue={getDefaultValue(fieldSchema) as number | undefined} min={fieldSchema.minimum} max={fieldSchema.maximum} // An `integer` field rejects the decimal point outright rather than @@ -692,7 +722,9 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - value={rawValue} + // Raw and default kept apart, not pre-resolved (#2026). + value={values[fieldName]} + defaultValue={getDefaultValue(fieldSchema)} onChange={(val) => handleFieldChange(fieldName, val)} onValidityChange={reportFieldValidity} /> From c6edc13a9f47a4fb2f410d78781d348bbbb1a241 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 00:44:53 -0400 Subject: [PATCH 163/941] docs: mark the JSON half of #2026 latent, not reproducible today MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the code and test comments still said a defaulted array or object argument *was* uneditable. It would be, but not through today's seeding path — `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the substitution returns the same reference and the re-sync never fires. It needs a structurally-equal but distinct value object, which parsed wire/deep-link values and rebuilt nested-object defaults produce, and which these tests construct. The number field has no such protection and did revert in the app. State the boundary in all three places, and stop claiming the distinct-object seed is how the Tools tab seeds the field, which it is not. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 27 ++++++++++++++----- .../groups/SchemaForm/SchemaForm.tsx | 22 ++++++++++----- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 89ee31bb..e963ff8c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1651,10 +1651,22 @@ describe("JSON editor escaping (#1853, #1856, #1885)", () => { // A schema `default` is what a field *opens* with, not a value re-imposed on // every unsendable draft. Unsendable text reports `undefined` by design, and -// `resolveValue` used to turn that straight back into the default — a real -// change from the previous value, so the draft/value re-sync rewrote the box -// and reverted the keystroke. Almost every edit passes through an unsendable -// state, which left a defaulted array or object argument uneditable. +// `resolveValue` used to turn that straight back into the default — a change +// from the previous value, so the draft/value re-sync rewrote the box and +// reverted the keystroke. +// +// The two halves differed in how reachable they were, which the tests below +// mirror deliberately: +// +// - **The number field reverted in the app.** Numbers compare by value, so +// clearing a defaulted box (value `3` → resolved default `30`) always fired +// the re-sync and refilled itself. +// - **The JSON field was latent.** `collectSchemaDefaults` assigns +// `fieldSchema.default` itself, so a field seeded from the schema holds the +// *same reference* the substitution returns and nothing fires. It needs a +// structurally-equal but distinct value object to become observable — which +// is what parsed wire/deep-link values and rebuilt nested-object defaults +// produce, and what these tests construct. describe("SchemaForm defaulted fields (#2026)", () => { function DefaultHarness({ schema, @@ -1681,8 +1693,10 @@ describe("SchemaForm defaulted fields (#2026)", () => { it("keeps a keystroke typed into a defaulted JSON field", async () => { const user = userEvent.setup(); - // Seeded the way the Tools tab seeds it — from the schema's defaults, as a - // separate value than the schema's own. Sharing one reference hid this. + // Seeded with a value that is equal to the schema default but is not the + // same object — how values parsed off the wire or out of a deep link + // arrive. Sharing the reference (what `collectSchemaDefaults` produces) is + // what keeps this latent rather than what makes it safe. renderWithMantine( <DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />, ); @@ -1697,6 +1711,7 @@ describe("SchemaForm defaulted fields (#2026)", () => { it("lets a defaulted JSON field be edited to a new value", async () => { const user = userEvent.setup(); + // Distinct-object seed again, for the reason above. renderWithMantine( <DefaultHarness schema={arrayWithDefault} initial={{ tags: ["a"] }} />, ); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index a849ab50..53edeeca 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -268,12 +268,22 @@ function SchemaJsonField({ // // The value compared here is the field's *raw* entry, never `resolveValue`'s // default substitution (#2026). Unsendable text reports `undefined`, and a - // defaulted field resolves that straight back to its default — a real change - // from the previous value, so this would rewrite the draft to the default and - // revert the keystroke. Almost every edit passes through an unsendable state, - // which made a defaulted array or object argument uneditable. The default - // belongs to what the field *opens* with, seeded above, not to what is - // re-imposed while it is being typed into. + // defaulted field resolves that straight back to its default — a change from + // the previous value, so comparing against the resolved one would rewrite the + // draft to the default and revert the keystroke. Almost every edit passes + // through an unsendable state, so a defaulted array or object argument would + // be uneditable. + // + // Would be, not was: this is latent under today's seeding, where + // `collectSchemaDefaults` assigns `fieldSchema.default` itself, so the + // substitution returns the *same reference* and `useValueChange` never fires. + // It goes live as soon as the value is a structurally-equal but distinct + // object — parsed from the wire or a deep link, or a nested-object default, + // which `collectSchemaDefaults` rebuilds per call. `SchemaNumberInput` has no + // such accidental protection, since a number compares by value. + // + // Either way the default belongs to what the field *opens* with, seeded + // above, not to what is re-imposed while it is being typed into. useValueChange(value, (next) => { if (!isSameJson(parseJsonDraft(draft), next)) { setDraft(next === undefined ? "" : serializeJson(next)); From d3b70a6329bee2c65cd9c25d01fa7237537c3c9e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 00:53:16 -0400 Subject: [PATCH 164/941] fix(web): keep an explicit null out of the number field's default fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: seeding with `value ?? defaultValue` also substitutes the default when the raw value is an explicit `null` — a nullable number field the user cleared, or one the server sent — where `resolveValue` previously kept the null and the box rendered empty. A nullable schema with a non-null default would have opened showing that default over a real value. Test the `undefined` case explicitly, as `SchemaJsonField` already does, and pin it: the new test fails with `'30'` where `''` belongs. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 19 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index e963ff8c..3c7193df 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1742,6 +1742,25 @@ describe("SchemaForm defaulted fields (#2026)", () => { expect(n.value).toBe(""); }); + // An explicit `null` is a value, not an absent one, so a non-null default + // must not be substituted for it. Reachable for a nullable number field the + // user cleared, or one the server sent as null. + it("shows an explicit null as empty, not as the default", () => { + const schema: InspectorFormSchema = { + type: "object", + properties: { + n: { + title: "N", + anyOf: [{ type: "integer" }, { type: "null" }], + default: 30, + }, + }, + }; + renderWithMantine(<DefaultHarness schema={schema} initial={{ n: null }} />); + + expect((screen.getByLabelText(/^N$/) as HTMLInputElement).value).toBe(""); + }); + it("still re-syncs a defaulted field when the value changes externally", async () => { function ExternalHarness() { const [values, setValues] = useState<Record<string, unknown>>({ diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 53edeeca..acd59ca7 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -379,8 +379,13 @@ function SchemaNumberInput({ onValidityChange, ...inputProps }: SchemaNumberInputProps) { + // `undefined` means "no value supplied", which is what the default is for. + // An explicit `null` is a value — a nullable number the user cleared, or one + // the server sent — so it must not be overwritten by a non-null default; it + // displays as the empty box `resolveValue` produced before. Hence the + // `undefined` test rather than `??`, matching `SchemaJsonField`. const [draft, setDraft] = useState<string | number>( - value ?? defaultValue ?? "", + (value === undefined ? defaultValue : value) ?? "", ); // Compared against the raw value, never the default-substituted one, for the From 27fe15976fb273bf83792299a1a1803a6f35f168 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 01:00:48 -0400 Subject: [PATCH 165/941] fix(web): admit null in the number field's value and default prop types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: a nullable number schema really does produce `null` for both the raw value and the schema default, but the props declared only `number | undefined` and the call site cast the null away. The runtime already handled it — the initializer's `?? ""` exists for exactly that — so the types were the part that was wrong, and a cast hiding it is how a future null bug goes unseen by the compiler. Widen both to `number | null | undefined` and drop the narrowing casts, keeping the distinction the initializer encodes: `undefined` takes the default, `null` is a value and renders as an empty box. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.tsx | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index acd59ca7..46103ba0 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -337,10 +337,17 @@ interface SchemaNumberInputProps { description?: string; withAsterisk: boolean; disabled: boolean; - /** Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. */ - value: number | undefined; - /** The schema `default`, used to seed the draft only (#2026). */ - defaultValue: number | undefined; + /** + * Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. + * + * `null` is admitted alongside `undefined` because a nullable number schema + * produces it, and the two are not interchangeable here: `undefined` means + * "no value supplied" and takes `defaultValue`, while `null` is a value and + * displays as an empty box. + */ + value: number | null | undefined; + /** The schema `default`, used to seed the draft only (#2026). May be `null`. */ + defaultValue: number | null | undefined; min?: number; max?: number; allowDecimal: boolean; @@ -627,9 +634,12 @@ export function SchemaForm({ description={description} withAsterisk={isRequired} disabled={disabled} - // Raw and default kept apart, not pre-resolved (#2026). - value={values[fieldName] as number | undefined} - defaultValue={getDefaultValue(fieldSchema) as number | undefined} + // Raw and default kept apart, not pre-resolved (#2026). Both admit + // `null`, which a nullable number schema really produces. + value={values[fieldName] as number | null | undefined} + defaultValue={ + getDefaultValue(fieldSchema) as number | null | undefined + } min={fieldSchema.minimum} max={fieldSchema.maximum} // An `integer` field rejects the decimal point outright rather than From a945fb71c6e44f3101de602317c884ebde3de82d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 01:08:40 -0400 Subject: [PATCH 166/941] docs: correct how a null reaches the number field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the comments offered "a nullable number the user cleared" as a way to get an explicit `null` into this field. Clearing the box reports `undefined` — `toNumericValue("")` says so, and an existing test pins it — so the only way `null` arrives is from parent state: a value received from the server, restored from a deep link, or written by a caller for a nullable schema. Say that in all three places instead. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../groups/SchemaForm/SchemaForm.test.tsx | 7 +++++-- .../groups/SchemaForm/SchemaForm.tsx | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 3c7193df..b1d3182f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1743,8 +1743,11 @@ describe("SchemaForm defaulted fields (#2026)", () => { }); // An explicit `null` is a value, not an absent one, so a non-null default - // must not be substituted for it. Reachable for a nullable number field the - // user cleared, or one the server sent as null. + // must not be substituted for it. Note the field itself never emits `null` — + // clearing it reports `undefined` (pinned by "passes undefined to onChange + // when a number field is cleared") — so this arrives from parent state: a + // value received from the server, restored from a deep link, or written by a + // caller for a nullable schema. it("shows an explicit null as empty, not as the default", () => { const schema: InspectorFormSchema = { type: "object", diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 46103ba0..152fe40f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -341,9 +341,9 @@ interface SchemaNumberInputProps { * Raw, not `resolveValue`-substituted — see `SchemaJsonFieldProps.value`. * * `null` is admitted alongside `undefined` because a nullable number schema - * produces it, and the two are not interchangeable here: `undefined` means - * "no value supplied" and takes `defaultValue`, while `null` is a value and - * displays as an empty box. + * produces it — by way of parent state, never by clearing the box — and the + * two are not interchangeable here: `undefined` means "no value supplied" + * and takes `defaultValue`, while `null` is a value and displays empty. */ value: number | null | undefined; /** The schema `default`, used to seed the draft only (#2026). May be `null`. */ @@ -387,10 +387,15 @@ function SchemaNumberInput({ ...inputProps }: SchemaNumberInputProps) { // `undefined` means "no value supplied", which is what the default is for. - // An explicit `null` is a value — a nullable number the user cleared, or one - // the server sent — so it must not be overwritten by a non-null default; it - // displays as the empty box `resolveValue` produced before. Hence the - // `undefined` test rather than `??`, matching `SchemaJsonField`. + // An explicit `null` is a value, so it must not be overwritten by a non-null + // default; it displays as the empty box `resolveValue` produced before. + // Hence the `undefined` test rather than `??`, matching `SchemaJsonField`. + // + // Clearing the box does *not* produce that `null` — `toNumericValue("")` + // reports `undefined`, which is the behavior the "passes undefined to + // onChange when a number field is cleared" test pins. A `null` reaches this + // field only from parent state: a value received from the server, restored + // from a deep link, or written by a caller for a nullable schema. const [draft, setDraft] = useState<string | number>( (value === undefined ? defaultValue : value) ?? "", ); From ae0e9d53a312fb04ef80aee5f6c515c05c3116a7 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 12:15:41 -0400 Subject: [PATCH 167/941] test(tui): settle input handlers at the keypress sites too, and pin the contract Extends the previous commit's fix rather than changing it. `waitUntil` now settles, but the same window is open wherever a key is written without having just polled: `press()` reaches its `stdin.write` on a turn that may still have React's passive-effect flush queued (straight after a plain `tick`, or after an earlier key committed a render), and `mount()`'s absorbing keypress lands one tick after the mount commit. Both now `await settleInputHandlers()` before writing, so "frame visible" implies "input handlers armed" at every dispatch point, not only after a frame wait. Adds a `test helpers` case that pins the contract: a `setImmediate` sentinel stands in for the pending effect flush and must have run by the time `waitUntil` resolves. Verified it fails when the yield is removed, so a future regression surfaces here instead of as a differently-named flaky OAuth test under coverage instrumentation. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/tui/__tests__/App.test.tsx | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 9025b52e..1efcd408 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -507,6 +507,9 @@ function renderApp(servers: Record<string, TuiServer>) { async function mount(servers: Record<string, TuiServer>) { const r = renderApp(servers); await tick(); + // The mount commit's effect flush can still be queued behind this tick, and + // this write is the one that absorbs the dropped first keypress. + await settleInputHandlers(); r.stdin.write("x"); await tick(); return r; @@ -532,6 +535,11 @@ const ENTER = "\r"; */ async function press(r: RenderResult, keys: string[]) { for (const k of keys) { + // Same hazard `waitUntil` guards against, at the other end: a caller can + // reach here on a turn that still has React's passive-effect flush queued + // (e.g. straight after a plain `tick`, or after an earlier key committed a + // render), so settle before every write rather than only after a poll. + await settleInputHandlers(); r.stdin.write(k); await tick(); await tick(); @@ -646,6 +654,23 @@ afterEach(() => { while (mounted.length) mounted.pop()?.unmount(); }); +// Pins the synchronization contract the OAuth step-up assertions depend on +// (#1942). The `setImmediate` sentinel below stands in for React's pending +// passive-effect flush — the turn where ink re-arms `useInput`. If `waitUntil` +// ever returns without yielding a check-phase turn, the sentinel has not run +// and this fails, instead of the regression resurfacing as a differently-named +// flaky OAuth test under coverage instrumentation. +describe("test helpers", () => { + it("waitUntil settles input handlers before resolving", async () => { + let flushed = false; + setImmediate(() => { + flushed = true; + }); + await waitUntil(() => true); + expect(flushed).toBe(true); + }); +}); + describe("App (foundation)", () => { it("renders the server list with the MCP Servers header", async () => { const r = renderApp(stdioServer()); From 7f9be24bf3f1f34516ec0452b6f8c5fd18d11359 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 12:17:15 -0400 Subject: [PATCH 168/941] chore(scripts): derive dep-lockstep candidates from what enters each tsc program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verify:dep-lockstep` derived its candidate set from the packages the shared first-party sources named *directly*, so a package whose declarations reach a program only through another package's `.d.ts` was invisible to it — it could skew across installs and recreate the two-copy blow-up #1896 was about while the guard stayed green. `@modelcontextprotocol/sdk` was the live example: never written in first-party code, yet 16 of its `.d.ts` files land in clients/web's test program. Replace the derivation with the measured one: list every client tsconfig project with `tsc --listFilesOnly`, map each resolved `node_modules` file to its owning install and package, and keep the packages that reach ONE program from TWO installs. That is exactly the set that can put two structurally distinct copies of a type in front of one checker. The rest of the guard is unchanged — top-level lockfile comparison, deny-by-default, `TOLERATED_SKEW` as a name allowlist scoped to within-a-major. The listing machinery moves to `scripts/lib/tsc-program.mjs`, shared with `verify:typecheck-coverage`, so the two guards can't disagree about what a program contains. It is deliberately not cached to disk between the two processes: a stale fingerprint would make a guard measure a program that no longer exists and pass on a real miss. The second pass costs ~14s. `TOLERATED_SKEW` is now empty. `jose` and `@modelcontextprotocol/ext-apps` are root-only since #1970 and cannot skew; `react` (whose types are `@types/react`, resolved from one install) and `hono` never reach one program from two installs. Each would be a rationale for a skew that cannot occur. Closes #1965 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 2 +- AGENTS.md | 11 +- README.md | 6 +- scripts/lib/tsc-program.mjs | 382 ++++++++++++++++++ scripts/lib/tsc-program.test.mjs | 259 ++++++++++++ scripts/verify-dep-lockstep.main.test.mjs | 250 +++++++++--- scripts/verify-dep-lockstep.mjs | 440 +++++++++------------ scripts/verify-dep-lockstep.test.mjs | 281 ++----------- scripts/verify-typecheck-coverage.mjs | 231 +---------- scripts/verify-typecheck-coverage.test.mjs | 142 +------ 10 files changed, 1070 insertions(+), 934 deletions(-) create mode 100644 scripts/lib/tsc-program.mjs create mode 100644 scripts/lib/tsc-program.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0bc70bfc..32122205 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -100,7 +100,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo ## Gates and PR hygiene - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. -- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this, so a PR bumping a package that the shared sources import should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. +- **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. diff --git a/AGENTS.md b/AGENTS.md index de3945eb..eac73b7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,11 @@ v2/main/ ├── scripts/ # Root build/verify tooling: install-clients.mjs (the │ # postinstall cascade), the smoke-*.mjs runners, │ # verify-build-gate / verify-format-coverage / -│ # verify-typecheck-coverage, pack-and-verify.mjs, -│ # and lib/ shared helpers. Prettier-gated via +│ # verify-typecheck-coverage / verify-dep-lockstep, +│ # pack-and-verify.mjs, and lib/ shared helpers +│ # (tsc-program.mjs is the `tsc --listFilesOnly` +│ # measurement both coverage guards read a program +│ # through — #1965). Prettier-gated via │ # `format:check:scripts`; its own pure parsers are │ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification @@ -686,12 +689,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - ALWAYS do `npm run format` before committing — the **root** `format` auto-fixes `core/` (`format:core`), the root `scripts/` tooling (`format:scripts`), the root "shared" surface (`format:shared` — `test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`), and every client's scope in one shot. Every **client** format glob uses the uniform extension set `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (#1792) so a new-extension file can't slip the gate; `core/` stays `{ts,tsx}` and the shared surface `{ts,tsx,mts,cts}` (their surfaces can't hold the other extensions), and `npm run verify:format-coverage` (the first step of `validate`, #1792) is the backstop — it fails if any tracked source file is left uncovered by a `format:check` glob regardless of which glob was expected to catch it. `validate` runs `format:check` (the non-fixing variant, including `format:check:core`, `format:check:scripts`, and `format:check:shared`) and will fail in CI on any unformatted file, so always run the auto-fixer first rather than letting `format:check` catch it. - **`npm run ci` is the mandatory pre-push command** — it mirrors `.github/workflows/main.yml` (minus `npm install`): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `smoke` → Storybook play-function tests (installs Playwright chromium if needed). It now runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so `npm run ci` is a true superset of GitHub CI, and passing it locally means CI's gates will pass. Expect several minutes. **`npm run validate`** remains the fast inner-loop check during development (unit tests only — no coverage gate, no smoke, no Storybook), but it is **NOT** an acceptable substitute for `npm run ci` before pushing: `validate` runs `test`, not `test:coverage`, so it does **zero** coverage gating. Skipping the gate is how a push passes every fast local check and still fails CI (this exact gap broke PR #1601 on a function-coverage regression). -- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency *directly imported* by the shared sources resolves to two different versions across installs; transitive-only declarations are a known boundary, #1965), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). +- ALWAYS do `npm run format` before committing, then **`npm run ci`** before pushing. From the repo root, `validate` runs **`verify:format-coverage` first** (the #1792 guard — asserts every tracked source file is covered by a `format:check` glob), then **`verify:typecheck-coverage`** (the #1791 guard — asserts every tracked `.ts`/`.tsx`/`.mts`/`.cts` in each gated Node client, plus the non-client first-party TS like `core/` and `test-servers/src`, lands in a tsconfig project), then **`verify:dep-lockstep`** (the #1896 guard — asserts no dependency that reaches a single `tsc` program from two installs resolves to two different versions across them), then **`test:scripts`** (the guards' own parser unit tests, `node --test`), then the **`core/` gate** (`validate:core`), then chains the four per-client validations (`validate:web` → `validate:cli` → `validate:tui` → `validate:launcher`); each client delegates to its own `npm run validate` in its own folder (no coverage — fast). Every client is self-validating and the top level just chains them, building each client's bundle along the way (no cross-client build dependencies). - **`validate:core` is the root-owned format + lint gate (#1689, widened in #1778 and #1767).** Each client's `prettier`/`eslint` is scoped to its own dir, so nothing reached `core/`, the root `scripts/`, or the root "shared" surface before — `validate:core` closes that: it runs `format:check:core` (`prettier --check "core/**/*.{ts,tsx}"`) + `format:check:scripts` (`prettier --check "scripts/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"`, the root build/verify tooling — #1778) + `format:check:shared` + `lint:core` (`eslint "core/**/*.{ts,tsx}"` via the **root** `eslint.config.js`) + `lint:shared`. Use `npm run format:core` / `npm run format:scripts` / `npm run format:shared` to auto-fix (all folded into the root `format`). The **shared surface** (#1767) is `test-servers/src/**/*.{ts,tsx,mts,cts}`, the root `vitest.shared.mts`, and the root `eslint.config.js` — first-party code no client's `eslint .` / `prettier` reaches; it is both prettier-gated (`format:check:shared`) and eslint-gated (`lint:shared`, via a second `files` block in the root `eslint.config.js` scoped to Node globals). The `scripts/` gate is prettier-only — the root has no eslint config for `.mjs`. The root carries prettier/eslint as devDependencies for this; `core/` is isomorphic (browser + Node globals, no JSX today — the `{ts,tsx}` glob future-proofs against a `core/**/*.tsx`). The root `eslint.config.js` honors an `_`-prefix as the intentionally-unused marker (`argsIgnorePattern`/`varsIgnorePattern`/`caughtErrorsIgnorePattern: '^_'`). **prettier is pinned to an exact version** (not a caret) in all five `package.json`s (#1790) so the gate's verdict can't shift with an in-range patch bump. - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from the packages the shared first-party TypeScript imports — `core/`, `test-servers/src`, and the individually-named `vitest.shared.mts` (root-owned and imported by every client's vitest config) — so a new shared dependency is covered without editing the guard. It reads the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and fails, **deny-by-default**, on any candidate held at two versions across installs. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; `react`, `hono`, `jose`, and `@modelcontextprotocol/ext-apps` are listed today. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources import, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts`, and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions themselves still come from the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and any candidate held at two versions fails, **deny-by-default**. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 467a4fc0..0bca3291 100644 --- a/README.md +++ b/README.md @@ -294,14 +294,14 @@ Each client self-validates from its own folder; the root scripts chain them. The | Script | What it does | | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency the shared sources directly import skews across installs) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from what those shared sources import, compares the committed lockfiles' top-level entries, and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Compares those against the committed lockfiles' top-level entries and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — empty today — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/tsc-program.mjs b/scripts/lib/tsc-program.mjs new file mode 100644 index 00000000..189c13df --- /dev/null +++ b/scripts/lib/tsc-program.mjs @@ -0,0 +1,382 @@ +// What a `tsc` project actually pulls into its program — the shared measurement +// behind two guards, so the two can never disagree about what a program holds. +// +// • `verify:typecheck-coverage` (#1791) asks which FIRST-PARTY files land in a +// program, to prove every tracked source file gets a `tsc` pass. +// • `verify:dep-lockstep` (#1965) asks which INSTALLED PACKAGES land in one, to +// find the packages that can appear twice — from two different installs — in +// a single program, which is the whole failure mode #1896 was about. +// +// Both answers come from the same `tsc --listFilesOnly` run: it reports every +// file the program resolves, including the ones reached only through another +// file's imports and the `.d.ts` files under `node_modules`. That is the accurate +// measure — a tsconfig's `include` names only the roots. +// +// The listing is memoized per (client, project) for the life of the process. It +// is deliberately NOT cached to disk across the two guard processes: a stale +// cache would make either guard measure a program that no longer exists and pass +// on a real miss — failing open, the one way these gates must never fail. The +// second pass costs ~14s (measured, #1965); that is cheaper than the class of +// bug a fingerprint that misses one input would introduce. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { reachableScripts, tokenize } from "./npm-scripts.mjs"; + +export const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +// Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ +// tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. +export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); + +// A flag that makes a `tsc` pass list files without type-checking them (so it +// gates nothing). Case-insensitive — tsc's own option parsing is. +export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); + +/** + * The tsconfig projects a client's `typecheck` names, harvested from **every** + * script reachable from `typecheck` (not just the one string) so a delegating + * `typecheck` (`npm run typecheck:src && …`) still counts — matching how + * `verify-format-coverage.mjs` harvests globs across reachable scripts. Splits + * each script on `&&`/`||`/`;` so a flag on one command doesn't leak onto + * another. Each `tsc` command's project comes from `-p`/`--project` (or a + * `-b`/`--build` path); a `tsc` command with **no** project flag resolves the + * implicit `./tsconfig.json` (tsc's own default), so that idiomatic form counts + * too. Returns `{ projects, neutered }`: `neutered` names any project whose own + * command carries `--noCheck`/`--nocheck` or `--listFilesOnly` (matched + * case-insensitively — tsc's option parsing is) — a pass that lists files + * without type-checking them, which would otherwise satisfy the typecheck guard + * while checking nothing. + * + * A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists + * nothing itself; {@link resolveLeafProjects} expands it to its references. + * + * Minor limitations, all unreachable with the plain `-p --noEmit` passes here: + * the implicit-`./tsconfig.json` fallback assumes **no file operands** (`tsc + * <file>` ignores the config and checks only that file, but would be credited + * the whole config's file list); the `--noCheck`/`--listFilesOnly` detection + * ignores a following boolean, so the contrived explicit `--noCheck false` + * (checking *on*) is still treated as disabling; and the `&&`/`||`/`;` split + * runs before tokenizing, so a quoted operator inside an arg would split + * mid-token (project paths carry none of those). + */ +export function typecheckProjects(scripts) { + const projects = []; + const neutered = []; + const isFlag = (t) => t.startsWith("-"); + const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); + for (const name of reachableScripts(scripts, "typecheck")) { + const cmd = scripts?.[name]; + if (typeof cmd !== "string") continue; + for (const segment of cmd.split(/&&|\|\||;/)) { + const tokens = tokenize(segment); + if (!tokens.some(isTsc)) continue; // only tsc commands name projects + const disabling = tokens.find(isDisablingFlag); + // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command + // with none uses the implicit `./tsconfig.json` (tsc's own default). + const named = []; + for (let i = 0; i < tokens.length; i++) + if (isProjectFlag(tokens[i]) && tokens[i + 1] && !isFlag(tokens[i + 1])) + named.push(tokens[i + 1]); + if (named.length === 0) named.push("tsconfig.json"); + for (const project of named) { + if (disabling) neutered.push({ project, flag: disabling }); + else projects.push(project); + } + } + } + return { projects, neutered }; +} + +/** + * The `references` paths declared in a tsconfig's raw text (a `tsc -b` solution + * config), or `[]` if it has none / isn't parseable. Paths are as written + * (relative to that tsconfig's own directory). + */ +export function parseTsconfigReferences(raw) { + try { + // Tolerate JSONC — block AND line comments + trailing commas (tsconfig + // allows all; block comments are in fact the style of every other tsconfig + // here). Block comments are stripped first so a `//` inside one doesn't + // survive; a `//` inside a string value (e.g. an `https://` URL) is a + // theoretical false strip this guard's tsconfigs never hit. + const cfg = JSON.parse( + raw + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, "") + .replace(/,(\s*[}\]])/g, "$1"), + ); + return Array.isArray(cfg.references) + ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") + : []; + } catch { + return []; + } +} + +export function tsconfigReferences(tsconfigRel) { + try { + return parseTsconfigReferences( + readFileSync(path.join(repoRoot, tsconfigRel), "utf8"), + ); + } catch { + return []; // unreadable file (e.g. a directory / missing path) + } +} + +/** + * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` + * client (like `clients/web`, which has no `typecheck` script) — the guards + * enroll such a client through these instead of exempting the whole tree. + */ +export function clientTsconfigReferences(clientDir) { + return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); +} + +/** + * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. + * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means + * `<dir>/tsconfig.json` — tsc's own rule. + */ +export function projectConfigFile(clientDir, project) { + const projectRel = path.posix.join(clientDir, project); + return projectRel.endsWith(".json") + ? projectRel + : path.posix.join(projectRel, "tsconfig.json"); +} + +/** + * A `references` entry `ref` (written relative to `fromConfigFile`'s own + * directory) as a `clientDir`-relative project path, the form the rest of the + * graph walk uses. + */ +export function refToProject(clientDir, fromConfigFile, ref) { + return path.posix.relative( + clientDir, + path.posix.join(path.posix.dirname(fromConfigFile), ref), + ); +} + +/** + * The leaf tsconfig projects `project` resolves to (paths relative to + * `clientDir`): itself if it lists files (or has no `references`), else its + * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": + * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how + * it's reduced to the real projects — and doing it here (not inside a single + * caller) is what lets every consumer follow the same graph. + */ +export function resolveLeafProjects(clientDir, project, seen = new Set()) { + if (seen.has(project)) return []; + seen.add(project); + // Lists first-party files → a real leaf. (An empty set is a solution config, + // or a config that errored — either way the reference expansion below is the + // right next step: a broken config yields no references either.) + if (projectSourceFiles(clientDir, project).size > 0) return [project]; + const configFile = projectConfigFile(clientDir, project); + const refs = tsconfigReferences(configFile); + if (refs.length === 0) return [project]; // no files, no refs — itself + return refs.flatMap((ref) => + resolveLeafProjects( + clientDir, + refToProject(clientDir, configFile, ref), + seen, + ), + ); +} + +/** + * Every file ONE project's program resolves, as absolute POSIX paths and with no + * filtering at all — first-party sources, `node_modules` declarations, and the + * out-of-repo `lib.*.d.ts` — plus the config diagnostic if `tsc` exited + * non-zero. The two consumers want different slices of the files, so the slicing + * happens in {@link projectSourceFiles} / {@link projectPackageFiles} rather than + * here; they differ on the `error` too, which is why it is reported rather than + * only warned about. Memoized per (client, project): the same project is listed + * by `resolveLeafProjects` and again by whichever slice the caller asks for. + */ +const listingCache = new Map(); +export function rawProjectListing(clientDir, project) { + const key = `${clientDir}|${project}`; + const cached = listingCache.get(key); + if (cached) return cached; + let stdout; + let error = null; + try { + stdout = execFileSync( + "npx", + ["--no-install", "tsc", "-p", project, "--listFilesOnly"], + { + cwd: path.join(repoRoot, clientDir), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1 << 28, + }, + ); + } catch (err) { + // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or + // malformed tsconfig) still exits non-zero while printing the resolved file + // list; keep stdout so a broken config doesn't mask what the callers measure. + // Echo the diagnostic — the guards run before any client's own `typecheck`, + // so this is the first place a bad `-p` config surfaces, and without the + // reason the resulting report is misleading. tsc prints config errors + // (`error TS…`) to stdout, so scan both streams for them. + stdout = typeof err.stdout === "string" ? err.stdout : ""; + const streams = + stdout + "\n" + (typeof err.stderr === "string" ? err.stderr : ""); + error = streams + .split("\n") + .filter((l) => /error TS\d+/.test(l)) + .join("\n") + .trim(); + console.warn( + `tsc -p ${project} (in ${clientDir}) exited non-zero:\n${error || "(no diagnostic captured)"}\n`, + ); + // An empty diagnostic still has to read as failure downstream, so the + // caller sees a string either way — never `""`. + error ||= "(no diagnostic captured)"; + } + const files = stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .map((abs) => abs.split(path.sep).join("/")); + const listing = { files, error }; + listingCache.set(key, listing); + return listing; +} + +/** + * The `tsc` diagnostic for a project whose listing exited non-zero, or null. A + * failed config still prints a file list — a partial, wrong one — so a consumer + * that must not measure a program it couldn't resolve has to ask. + */ +export function projectListingError(clientDir, project) { + return rawProjectListing(clientDir, project).error; +} + +/** + * Whether a repo-relative path is an installed file rather than a first-party + * one. Matched by path SEGMENT, not substring, so a first-party directory whose + * name merely embeds the word (`core/node_modules_fixtures/`) is not mistaken + * for an install — the same segment rule {@link classifyModulePath} applies. + */ +const isInstalledPath = (rel) => rel.split("/").includes("node_modules"); + +/** + * Repo-relative POSIX paths of the first-party files ONE project (no reference + * expansion) typechecks. Absolute paths outside the repo root (`lib.d.ts`) and + * anything under `node_modules` are dropped; the aliased `core/` + + * `test-servers/` sources stay in the set but are harmless — the set is only ever + * queried with in-repo paths. + */ +export function projectSourceFiles(clientDir, project) { + const covered = new Set(); + for (const abs of rawProjectListing(clientDir, project).files) { + const rel = path.relative(repoRoot, abs).split(path.sep).join("/"); + if (rel.startsWith("..") || isInstalledPath(rel)) continue; + covered.add(rel); + } + return covered; +} + +/** + * Repo-relative POSIX paths of the INSTALLED files ONE project's program + * resolves — everything under some `node_modules` inside the repo. Out-of-repo + * paths are dropped: an absolute `lib.d.ts` under the toolchain isn't an install + * this repo can align. + */ +export function projectPackageFiles(clientDir, project) { + const covered = new Set(); + for (const abs of rawProjectListing(clientDir, project).files) { + const rel = path.relative(repoRoot, abs).split(path.sep).join("/"); + if (rel.startsWith("..") || !isInstalledPath(rel)) continue; + covered.add(rel); + } + return covered; +} + +/** + * The install a repo-relative `node_modules` path belongs to, and the package it + * holds — `{ installRoot, name }`, or null for a path that names neither. + * + * The install root is the prefix before the **first** `node_modules` segment + * (`.` for the repo root, `clients/web` for a client install), while the package + * name comes from after the **last** one. That asymmetry is deliberate: a + * *nested* `node_modules/a/node_modules/b` is npm resolving a transitive + * conflict **inside one install**, which is routine and not a cross-install + * skew — the same reason the lockfile comparison reads only top-level entries. + * Folding it onto its outermost install is what keeps the candidate set the + * handful of genuinely install-crossing packages instead of every transitive + * duplicate in the tree. + */ +export function classifyModulePath(rel) { + const parts = rel.split("/"); + const first = parts.indexOf("node_modules"); + if (first === -1) return null; + const last = parts.lastIndexOf("node_modules"); + const head = parts[last + 1]; + // `node_modules/.bin/…`, `node_modules/.package-lock.json`: npm's own + // bookkeeping, not a package. + if (!head || head.startsWith(".")) return null; + const name = head.startsWith("@") + ? parts[last + 2] && `${head}/${parts[last + 2]}` + : head; + if (!name) return null; // a bare `node_modules/@scope` path names no package + return { + installRoot: first === 0 ? "." : parts.slice(0, first).join("/"), + name, + }; +} + +/** + * The packages that enter a SINGLE program from two different installs — the + * only ones whose version skew can put two structurally-distinct copies of one + * type in front of the type checker. + * + * `programs` is an iterable of `{ label, files }`, one per leaf tsconfig project, + * `files` being repo-relative `node_modules` paths. Returns a Map keyed by + * package name, each value `{ installRoots, programs }` naming where it was seen + * — the diagnostic a failure needs. + * + * The two-install test is applied **within one program**, not across the run: a + * package reached from the root install in web's program and from the client + * install in cli's program is two copies in two *separate* type checks, which + * nothing has to relate. + * + * Note what TypeScript's own package-identity dedup does to this, because it + * makes the measure self-correcting rather than leaky. Two copies of a package + * at the **same** name@version are collapsed into a redirect, and a redirected + * `.d.ts` does not resolve its own imports — so an aligned package's transitive + * dependencies are loaded once, from one install, and never become candidates. + * That is the right answer, not a miss: one copy in the program is one copy for + * the checker to instantiate, whatever the tree looks like on disk. The moment + * the pair skews, the redirect stops applying, both copies enter the program, + * and the package becomes a candidate — which is exactly when it matters. + */ +export function crossInstallPackages(programs) { + const found = new Map(); + for (const { label, files } of programs) { + const roots = new Map(); // name -> Set(installRoot), within THIS program + for (const rel of files) { + const hit = classifyModulePath(rel); + if (!hit) continue; + if (!roots.has(hit.name)) roots.set(hit.name, new Set()); + roots.get(hit.name).add(hit.installRoot); + } + for (const [name, installRoots] of roots) { + if (installRoots.size < 2) continue; + if (!found.has(name)) + found.set(name, { installRoots: new Set(), programs: new Set() }); + const entry = found.get(name); + for (const root of installRoots) entry.installRoots.add(root); + entry.programs.add(label); + } + } + return found; +} diff --git a/scripts/lib/tsc-program.test.mjs b/scripts/lib/tsc-program.test.mjs new file mode 100644 index 00000000..fa9bb9e1 --- /dev/null +++ b/scripts/lib/tsc-program.test.mjs @@ -0,0 +1,259 @@ +// Table-driven tests for the shared `tsc` program helpers — the machinery both +// `verify:typecheck-coverage` (#1791) and `verify:dep-lockstep` (#1965) read a +// program through. One case per rule; the comment names the rule, so relaxing +// one shows up as a deleted assertion rather than a quiet behavior shift. The +// script-parsing and tsconfig-graph cases moved here with their code when the +// two guards were unified; their `(rN)` tags are the #1799 review rounds that +// found them. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classifyModulePath, + crossInstallPackages, + isDisablingFlag, + isTsc, + parseTsconfigReferences, + projectConfigFile, + refToProject, + typecheckProjects, +} from "./tsc-program.mjs"; + +test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => { + for (const t of [ + "tsc", + "node_modules/.bin/tsc", + "./node_modules/.bin/tsc.cmd", + ]) + assert.ok(isTsc(t), t); + for (const t of ["vitest", "prettier", "tscx", "atsc"]) + assert.ok(!isTsc(t), t); +}); + +test("isDisablingFlag: case-insensitive (r18)", () => { + for (const t of [ + "--noCheck", + "--nocheck", + "--listFilesOnly", + "--LISTFILESONLY", + ]) + assert.ok(isDisablingFlag(t), t); + for (const t of ["--noEmit", "-p", "--project", "noCheck"]) + assert.ok(!isDisablingFlag(t), t); +}); + +test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]); + assert.equal(neutered.length, 0); + + // A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json. + assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [ + "tsconfig.json", + ]); + + // Path-invoked binary still counts (r18). + assert.deepEqual( + typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" }) + .projects, + ["tsconfig.json"], + ); + + // A quoted project path (r17). + assert.deepEqual( + typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, + ["tsconfig.test.json"], + ); + + // `--project` long form, and `-b`/`--build` project paths (r13). + const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects; + assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [ + "tsconfig.json", + ]); + assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]); + assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback +}); + +test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { + const { projects, neutered } = typecheckProjects({ + typecheck: + "tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json", + }); + assert.deepEqual(projects, ["tsconfig.test.json"]); + assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]); +}); + +test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => { + const { projects } = typecheckProjects({ + typecheck: "npm run typecheck:src && npm run typecheck:test", + "typecheck:src": "tsc --noEmit -p tsconfig.json", + "typecheck:test": "tsc --noEmit --project tsconfig.test.json", + }); + assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]); +}); + +test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => { + const refs = (raw) => parseTsconfigReferences(raw); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]); + assert.deepEqual( + refs('/* solution */\n{ "references": [{ "path": "./a" }] }'), + ["./a"], + ); + assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [ + "./a", + ]); + assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma + assert.deepEqual(refs('{ "files": [] }'), []); // no references + assert.deepEqual(refs("{ not json"), []); // malformed + assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path +}); + +test("projectConfigFile: directory-form entry means <dir>/tsconfig.json (r26)", () => { + assert.equal( + projectConfigFile("clients/cli", "tsconfig.test.json"), + "clients/cli/tsconfig.test.json", + ); + assert.equal( + projectConfigFile("clients/cli", "packages/a"), + "clients/cli/packages/a/tsconfig.json", + ); + assert.equal( + projectConfigFile("clients/cli", "."), + "clients/cli/tsconfig.json", + ); +}); + +test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => { + // A ref is relative to the tsconfig that declares it, not to clientDir. + assert.equal( + refToProject( + "clients/web", + "clients/web/tsconfig.json", + "./tsconfig.app.json", + ), + "tsconfig.app.json", + ); + assert.equal( + refToProject( + "clients/web", + "clients/web/sub/tsconfig.json", + "../other.json", + ), + "other.json", + ); + assert.equal( + refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"), + "sub/deep", + ); +}); + +test("classifyModulePath: install root is the OUTERMOST node_modules, package the innermost (#1965)", () => { + assert.deepEqual(classifyModulePath("node_modules/zod/index.d.ts"), { + installRoot: ".", + name: "zod", + }); + assert.deepEqual( + classifyModulePath("clients/web/node_modules/zod/index.d.ts"), + { installRoot: "clients/web", name: "zod" }, + ); + // A scoped package keeps both segments. + assert.deepEqual( + classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"), + { installRoot: ".", name: "@modelcontextprotocol/sdk" }, + ); + // A NESTED copy folds onto its outermost install: npm resolving a transitive + // conflict inside one install is routine, not a cross-install skew, and the + // lockfile comparison ignores it for the same reason. + assert.deepEqual( + classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"), + { installRoot: ".", name: "zod" }, + ); + assert.deepEqual( + classifyModulePath( + "clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts", + ), + { installRoot: "clients/cli", name: "@scope/b" }, + ); +}); + +test("classifyModulePath: paths that name no package", () => { + for (const rel of [ + "core/mcp/client.ts", // first-party source + "clients/web/src/App.tsx", + "node_modules/.bin/tsc", // npm bookkeeping, not a package + "node_modules/.package-lock.json", + "node_modules/@scope", // a scope directory names no package + "node_modules", // the directory itself + ]) + assert.equal(classifyModulePath(rel), null, rel); +}); + +test("classifyModulePath: a path segment merely CONTAINING node_modules is not one", () => { + // Segment-wise matching, not substring: a first-party dir whose name embeds + // the word would otherwise be read as an install root. + assert.equal(classifyModulePath("core/node_modules_fixtures/a.ts"), null); +}); + +test("crossInstallPackages: two installs in ONE program is the whole test (#1965)", () => { + const found = crossInstallPackages([ + { + label: "clients/web/tsconfig.test.json", + files: [ + "node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + "clients/web/node_modules/react/index.d.ts", + ], + }, + ]); + assert.deepEqual([...found.keys()], ["zod"]); + assert.deepEqual([...found.get("zod").installRoots].sort(), [ + ".", + "clients/web", + ]); + assert.deepEqual( + [...found.get("zod").programs], + ["clients/web/tsconfig.test.json"], + ); +}); + +test("crossInstallPackages: two installs across SEPARATE programs is not a candidate", () => { + // Two copies the type checker never has to relate — each program sees one. + const found = crossInstallPackages([ + { label: "a", files: ["node_modules/zod/index.d.ts"] }, + { label: "b", files: ["clients/cli/node_modules/zod/index.d.ts"] }, + ]); + assert.equal(found.size, 0); +}); + +test("crossInstallPackages: a nested duplicate inside one install is not a candidate", () => { + const found = crossInstallPackages([ + { + label: "a", + files: [ + "node_modules/zod/index.d.ts", + "node_modules/other/node_modules/zod/index.d.ts", + ], + }, + ]); + assert.equal(found.size, 0); +}); + +test("crossInstallPackages: every program that saw both copies is recorded", () => { + const both = [ + "node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + ]; + const found = crossInstallPackages([ + { label: "web/app", files: both }, + { label: "web/test", files: both }, + { label: "cli/src", files: ["node_modules/zod/index.d.ts"] }, + ]); + assert.deepEqual([...found.get("zod").programs].sort(), [ + "web/app", + "web/test", + ]); +}); diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index c00379d7..eb410167 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -1,14 +1,21 @@ // End-to-end tests for the dep-lockstep guard's executable path (Copilot, // #1962). The sibling tests cover the pure helpers; nothing exercised `main()`, -// so a regression in source enumeration, install discovery, lockfile loading, +// so a regression in candidate derivation, install discovery, lockfile loading, // the sibling-guard vouch, or the nonzero exit on real skew would have left the // whole suite green. // // Each case builds a throwaway repo — the guard derives its root from its own // file location, so the script is copied into the fixture rather than pointed -// at one — `git add`s it (the enumeration is `git ls-files`, which reads the -// index), and runs the guard as a subprocess to assert the exit status and -// message. Run via `npm run test:scripts`. +// at one — `git add`s it (install discovery is by manifest, but the sibling +// `verify:format-coverage` vouch and git-based tooling expect a repo), and runs +// the guard as a subprocess to assert the exit status and message. +// +// Since #1965 the fixture must contain a REAL tsc program: the candidate set is +// derived from what `tsc --listFilesOnly` resolves, so each fixture ships two +// installs holding the same tiny stub packages, one client tsconfig whose +// program spans both (its own `src` resolves from the client install, the shared +// `core/` it includes resolves from the root — the actual v2 shape), and a +// symlinked real `typescript` to run it. Run via `npm run test:scripts`. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -30,7 +37,7 @@ import { fileURLToPath } from "node:url"; const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.join(scriptsDir, ".."); -/** The real typescript install, symlinked into each fixture so the guard resolves it. */ +/** The real typescript install, symlinked into each fixture so `npx tsc` runs. */ const typescriptDir = path.dirname( createRequire(path.join(repoRoot, "clients", "web", "package.json")).resolve( "typescript/package.json", @@ -52,11 +59,24 @@ const lock = (deps) => ({ }); /** - * Build a fixture repo: shared sources importing `zod` and `express`, a root - * install and one client install, and the guard itself. `rootDeps`/`webDeps` - * decide whether the two installs agree. + * Build a fixture repo whose one client program spans two installs. + * + * `outer` is named by first-party code in both trees; `inner` is named ONLY by + * `outer`'s own `.d.ts`, so it reaches the program transitively — the + * `@modelcontextprotocol/sdk` shape #1965 is about. Both stubs are installed + * under the root AND under `clients/web`, so each program holds two copies of + * each and both are candidates. `solo` is installed at the root only and + * imported only by the shared tree, so it can never be a candidate however its + * lockfile entries read. */ -function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { +function makeFixture({ + rootDeps, + webDeps, + scripts, + webScripts, + rawWebLock, + webTsconfig, +} = {}) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` // (always the resolved path) matches `process.argv[1]`, so launching it via @@ -81,34 +101,91 @@ function makeFixture({ rootDeps, webDeps, scripts, rawWebLock }) { "verify:dep-lockstep": "node scripts/verify-dep-lockstep.mjs", }, }); - write("core/client.ts", 'import { z } from "zod";\nexport const s = z;\n'); + + // The shared tree, compiled into the client's program but resolving its own + // imports from the ROOT install — the reason a package can appear twice. write( - "test-servers/src/server.ts", - 'import express from "express";\nexport const app = express;\n', + "core/client.ts", + 'import type { Outer } from "outer";\nimport type { Solo } from "solo";\nexport type FromCore = Outer | Solo;\n', ); + // The client's own source, resolving from the CLIENT install. write( - "vitest.shared.mts", - 'import path from "node:path";\nexport default path;\n', + "clients/web/src/main.ts", + 'import type { Outer } from "outer";\nexport type FromWeb = Outer;\n', ); - write("package-lock.json", lock(rootDeps)); - write("clients/web/package.json", { name: "web" }); - write("clients/web/package-lock.json", rawWebLock ?? lock(webDeps)); + write("clients/web/package.json", { + name: "web", + scripts: webScripts ?? { typecheck: "tsc --noEmit -p tsconfig.json" }, + }); + write( + "clients/web/tsconfig.json", + webTsconfig ?? { + compilerOptions: { + noEmit: true, + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + types: [], + }, + include: ["src/**/*.ts", "../../core/**/*.ts"], + }, + ); + + // Stub installs. `outer` pulls `inner` in through its own declarations. The + // installed version must match the lockfile's: TypeScript keys its + // package-identity dedup on name@version, so a stub that lied about its + // version would make the program under test differ from the one the lockfile + // describes. + const root = rootDeps ?? ALIGNED; + const web = webDeps ?? ALIGNED; + const stub = (installRoot, versions, name, body) => { + if (!versions[name]) return; + write(`${installRoot}node_modules/${name}/package.json`, { + name, + version: versions[name], + types: "index.d.ts", + }); + write(`${installRoot}node_modules/${name}/index.d.ts`, body); + }; + for (const [installRoot, versions] of [ + ["", root], + ["clients/web/", web], + ]) { + stub( + installRoot, + versions, + "outer", + 'import type { Inner } from "inner";\nexport type Outer = Inner;\n', + ); + stub(installRoot, versions, "inner", "export type Inner = number;\n"); + } + stub("", root, "solo", "export type Solo = string;\n"); + + write("package-lock.json", lock(root)); + write("clients/web/package-lock.json", rawWebLock ?? lock(web)); // The guard resolves its repo root from its own location, so it has to live - // inside the fixture; `lib/npm-scripts.mjs` comes along as its import. + // inside the fixture; its `lib/` imports come along. mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); for (const rel of [ "verify-dep-lockstep.mjs", path.join("lib", "npm-scripts.mjs"), + path.join("lib", "tsc-program.mjs"), ]) cpSync(path.join(scriptsDir, rel), path.join(dir, "scripts", rel)); - mkdirSync(path.join(dir, "node_modules"), { recursive: true }); + // A real typescript, plus the `.bin` entry `npx --no-install tsc` resolves by + // walking up from the client dir. + mkdirSync(path.join(dir, "node_modules", ".bin"), { recursive: true }); symlinkSync( typescriptDir, path.join(dir, "node_modules", "typescript"), "dir", ); + symlinkSync( + path.join(typescriptDir, "bin", "tsc"), + path.join(dir, "node_modules", ".bin", "tsc"), + ); execFileSync("git", ["init", "-q"], { cwd: dir }); execFileSync("git", ["add", "-A"], { cwd: dir }); @@ -134,69 +211,110 @@ function withFixture(options, fn) { } } -const ALIGNED = { zod: "4.4.3", express: "5.2.1" }; +const ALIGNED = { outer: "1.2.3", inner: "4.5.6", solo: "7.8.9" }; test("main: exits 0 when every install agrees", () => { - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + withFixture({}, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 0, out); assert.match(out, /verify:dep-lockstep — OK/); - // Both shared trees and the named shared file contributed their imports. - assert.match(out, /2 install-crossing dependencies/); + // `outer` alone: both installs hold it and both files land in the program. + // `solo` is installed at the root only, so it is not install-crossing — and + // `inner`, at the same version in both installs, is collapsed by + // TypeScript's package-identity redirect (see the note on `crossInstallPackages` + // in lib/tsc-program.mjs): only one copy is ever loaded, so there is nothing + // for the checker to relate. The next test is the version that matters. + assert.match(out, /1 install-crossing dependencies/); + assert.match(out, /1 tsc programs/); }); }); test("main: exits 1 and names the skewed package and every holder", () => { withFixture( - { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, + { rootDeps: { ...ALIGNED, outer: "1.0.0" }, webDeps: ALIGNED }, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /\bzod\b/); - assert.match(out, /4\.3\.6\s+\(\.\)/); - assert.match(out, /4\.4\.3\s+\(clients\/web\)/); - // `express` agrees, so it must not be reported. - assert.doesNotMatch(out, /^\s+express$/m); + assert.match(out, /\bouter\b/); + assert.match(out, /1\.0\.0\s+\(\.\)/); + assert.match(out, /1\.2\.3\s+\(clients\/web\)/); + // The program that saw both copies is named, so the claim is checkable. + assert.match(out, /seen in clients\/web\/tsconfig\.json/); + // `inner` agrees, so it must not be reported. + assert.doesNotMatch(out, /^\s+inner$/m); }, ); }); -test("main: a package imported only by test-servers/src is still checked", () => { - // Guards the enumeration of the *second* shared tree: if only `core/` were - // scanned, this skew would pass. +test("main: a package reached only through another package's .d.ts is caught (#1965)", () => { + // `inner` is never written in first-party code — it enters the program solely + // through `outer`'s declarations. The derivation this replaced read the + // shared sources' own imports and could not see it, which is how + // `@modelcontextprotocol/sdk` sat skewed across two installs while the guard + // stayed green. Its parent skews too, which is what puts both copies of the + // parent in the program to resolve from — the real #1965 shape, where + // `ext-apps` was split 1.7.4/1.7.5 alongside the SDK. withFixture( - { rootDeps: { ...ALIGNED, express: "5.0.0" }, webDeps: ALIGNED }, + { + rootDeps: { ...ALIGNED, outer: "1.0.0", inner: "4.0.0" }, + webDeps: ALIGNED, + }, (dir) => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /\bexpress\b/); + assert.match(out, /^\s+inner$/m); + assert.match(out, /4\.0\.0\s+\(\.\)/); + assert.match(out, /4\.5\.6\s+\(clients\/web\)/); }, ); }); -test("main: exits 1 when a configured shared source matches no file", () => { - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { - // Drop `core/` from the index — the other sources keep the file count - // nonzero, which is exactly what the old aggregate check missed. - execFileSync("git", ["rm", "-r", "-q", "--cached", "core"], { cwd: dir }); +test("main: a package that reaches the program from ONE install is not a candidate", () => { + // `solo` is installed at the root only, so no program ever holds two copies of + // it — its lockfile entries can disagree without TypeScript ever relating two + // declarations. Failing here would be the old derivation's false positive + // (which is what made an allowlist of inert names necessary). + withFixture( + { rootDeps: { ...ALIGNED, solo: "7.0.0" }, webDeps: ALIGNED }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.doesNotMatch(out, /\bsolo\b/); + }, + ); +}); + +test("main: exits 1 when a client names no tsconfig project", () => { + // No `typecheck` script and no `tsconfig.json` references: none of that + // client's programs is measured, so a skew reaching only them would pass. + withFixture({ webScripts: { build: "vite build" } }, (dir) => { + rmSync(path.join(dir, "clients", "web", "tsconfig.json")); const { status, out } = runGuard(dir); assert.equal(status, 1, out); - assert.match(out, /matched no tracked file/); - assert.match(out, /^\s+core$/m); + assert.match(out, /names no tsconfig project/); + }); +}); + +test("main: exits 1 when a program cannot be listed, rather than measuring nothing", () => { + // A broken tsconfig makes `tsc --listFilesOnly` resolve nothing. Reading that + // as "no candidates here" is the gate failing open. + withFixture({ webTsconfig: "{ this is not json" }, (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /exited non-zero/); }); }); test("main: exits 1 on a lockfile it cannot read, rather than failing open (Copilot, #1962)", () => { // A v1 lockfile has `dependencies` and no `packages` table. Treating it as an - // install that simply holds nothing would leave the *root's* zod unopposed + // install that simply holds nothing would leave the *root's* copy unopposed // and the skew below reported as aligned — the gate failing open. withFixture( { - rootDeps: { ...ALIGNED, zod: "4.3.6" }, - webDeps: ALIGNED, + rootDeps: { ...ALIGNED, outer: "1.0.0" }, rawWebLock: { lockfileVersion: 1, - dependencies: { zod: { version: "4.4.3" } }, + dependencies: { outer: { version: "1.2.3" } }, }, }, (dir) => { @@ -213,11 +331,9 @@ test("main: exits 1 on a lockfile with a `packages` table but no root entry", () // `""` root npm always writes is not a lockfile this guard can trust. withFixture( { - rootDeps: ALIGNED, - webDeps: ALIGNED, rawWebLock: { lockfileVersion: 3, - packages: { "node_modules/zod": { version: "4.4.3" } }, + packages: { "node_modules/outer": { version: "1.2.3" } }, }, }, (dir) => { @@ -232,22 +348,19 @@ test("main: exits 1 on an install whose lockfile is missing (Copilot, #1962)", ( // Enrolment is by `package.json`, so a missing lockfile is loud rather than a // silently absent row. The fixture IS skewed, so dropping the install instead // would leave the remaining holder unopposed and report aligned. - withFixture( - { rootDeps: { ...ALIGNED, zod: "4.3.6" }, webDeps: ALIGNED }, - (dir) => { - rmSync(path.join(dir, "clients", "web", "package-lock.json")); - const { status, out } = runGuard(dir); - assert.equal(status, 1, out); - assert.match(out, /no lockfile/); - assert.match(out, /clients\/web\/package-lock\.json/); - }, - ); + withFixture({ rootDeps: { ...ALIGNED, outer: "1.0.0" } }, (dir) => { + rmSync(path.join(dir, "clients", "web", "package-lock.json")); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /no lockfile/); + assert.match(out, /clients\/web\/package-lock\.json/); + }); }); test("main: exits 1 when the ROOT lockfile is missing", () => { // The worst variant: the root is the install every shared source resolves // from, so omitting it could let the guard pass on client locks alone. - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + withFixture({}, (dir) => { rmSync(path.join(dir, "package-lock.json")); const { status, out } = runGuard(dir); assert.equal(status, 1, out); @@ -257,8 +370,9 @@ test("main: exits 1 when the ROOT lockfile is missing", () => { }); test("main: a clients/ dir with no package.json is not an install", () => { - // Enrolling a stray directory would demand a lockfile it should never have. - withFixture({ rootDeps: ALIGNED, webDeps: ALIGNED }, (dir) => { + // Enrolling a stray directory would demand a lockfile it should never have, + // and a tsconfig project it has no way to name. + withFixture({}, (dir) => { mkdirSync(path.join(dir, "clients", "scratch"), { recursive: true }); writeFileSync( path.join(dir, "clients", "scratch", "notes.md"), @@ -269,11 +383,21 @@ test("main: a clients/ dir with no package.json is not an install", () => { }); }); +test("main: exits 1 when there is no client program to measure at all", () => { + // A moved `clients/` dir leaves the derivation with nothing to look at, and an + // empty candidate set from a broken enumeration reads exactly like a clean + // bill of health. + withFixture({}, (dir) => { + rmSync(path.join(dir, "clients"), { recursive: true, force: true }); + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /found no client program to measure/); + }); +}); + test("main: exits 1 when the root validate no longer runs the sibling guard", () => { withFixture( { - rootDeps: ALIGNED, - webDeps: ALIGNED, // `verify:format-coverage` dropped from the chain: the vouch must fail // even though the dependency versions themselves are fine. scripts: { diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 27b5812c..3fe3a40e 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -20,210 +20,89 @@ // `@modelcontextprotocol/*` schema is built out of zod generics. Aligning the // two copies — changing nothing else — returned the build to its baseline cost. // -// The candidate set is DERIVED, not hand-listed: it is the packages imported by -// the shared first-party TypeScript — `core/`, `test-servers/src`, and the -// root-owned `vitest.shared.mts` — the surfaces compiled into more than one -// client's program. Skew is then denied by default, with a small -// allowlist of packages verified to tolerate it (below). A dependency that -// starts skewing therefore fails `validate` and forces a decision, rather than -// surfacing months later as an unexplained OOM. +// The candidate set is DERIVED from what actually enters each `tsc` program +// (#1965): every client tsconfig project is listed with `tsc --listFilesOnly` +// (the shared `lib/tsc-program.mjs` machinery `verify:typecheck-coverage` reads +// too), each resolved `node_modules` file is mapped to its owning install and +// package, and a package that reaches ONE program from TWO installs is a +// candidate. That is exactly the set that can put two structurally-distinct +// copies of a type in front of one type checker — no more, no less. // -// KNOWN BOUNDARY (#1965): the candidate set covers packages the shared sources -// name *directly*. A package whose declarations reach the program only through -// another package's `.d.ts` is invisible here — `@modelcontextprotocol/sdk` is -// the live example, skewed root 1.29.0 vs `clients/web` 1.30.0 and present in -// web's program from both installs, yet never written in first-party code -// (the shared sources import the split `@modelcontextprotocol/client|core|…`). -// Two derivations were measured for closing this. A lockfile dependency -// closure is unusable — 155 packages, 25 of them skewed, nearly all irrelevant -// tooling (`chai`, `qs`, `iconv-lite`) — and it misses the SDK anyway. Reading -// what actually lands in each program (`tsc --listFilesOnly`, keeping packages -// present under two install roots) is both correct and small: 15 for -// `clients/web`, ~10 once nested duplicates are dropped. That is the right -// derivation and is tracked separately, since it changes what the guard -// measures and surfaces skews needing their own decisions. +// It replaced a derivation that read the packages the shared sources named +// *directly*, which could not see a package whose declarations arrive only +// through another package's `.d.ts`. `@modelcontextprotocol/sdk` was the live +// example: it is never written in first-party code (the shared sources import +// the split `@modelcontextprotocol/client|core|…`), yet 16 of its `.d.ts` files +// land in `clients/web`'s test program, so a second copy under +// `clients/web/node_modules` would have skewed unseen. The other derivation +// measured for #1965 — expanding the direct imports over the lockfiles' +// `dependencies` — was unusable: 155 packages, 25 of them skewed, nearly all +// irrelevant transitive tooling (`chai`, `qs`, `iconv-lite`), and it missed the +// SDK anyway. +// +// Skew among the candidates is then denied by default, with an allowlist of +// packages verified to tolerate it (below). A dependency that starts skewing +// fails `validate` and forces a decision, rather than surfacing months later as +// an unexplained OOM. import { readFileSync, existsSync, readdirSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { builtinModules, createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { rootReachesScript } from "./lib/npm-scripts.mjs"; +import { + clientTsconfigReferences, + crossInstallPackages, + projectListingError, + projectPackageFiles, + resolveLeafProjects, + typecheckProjects, +} from "./lib/tsc-program.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); -// The first-party source trees that are compiled into more than one install's -// `tsc` program, and so define which dependencies can appear twice in one -// program. `core/` is consumed by every client via the `@inspector/core` alias; -// `test-servers/src` is pulled into the web and cli test projects. -const SHARED_SOURCE_DIRS = ["core", "test-servers/src"]; - -// Individual root-owned TypeScript files that are shared the same way but sit -// outside those trees. `vitest.shared.mts` is imported by every client's vitest -// config, and `verify:typecheck-coverage` already treats it as shared -// non-client source. It imports only Node built-ins today — which is precisely -// why omitting it would go unnoticed until a third-party import appeared there, -// resolved from the root, and skewed (Copilot, #1962). -const SHARED_SOURCE_FILES = ["vitest.shared.mts"]; - // Packages whose cross-install skew is verified benign, each with the reason. // This is an allowlist of *names*, not of version pairs, so an ordinary patch // float within one of these does not churn the file — while any package NOT // listed here failing the check is a genuine, unreviewed new skew. // // Being listed is NOT a blanket exemption: it tolerates skew only *within a -// major version*. Each rationale below establishes that a patch/minor -// difference is harmless, which is not evidence that a React 18-vs-19 or Hono -// 4-vs-5 split across installs would be — that is a different type surface, and -// it fails like anything else (Copilot, #1962). +// major version*. A rationale establishes that a patch/minor difference is +// harmless, which is not evidence that a React 18-vs-19 or Hono 4-vs-5 split +// across installs would be — that is a different type surface, and it fails like +// anything else (Copilot, #1962). // // The admission test is the one the zod incident established: does the // package's public type surface consist of deeply recursive generics that // first-party code relates across the boundary? If yes it must stay in // lockstep; if no, a patch-level difference costs nothing. -const TOLERATED_SKEW = new Map([ - [ - "react", - "Types are shallow interfaces (`ReactNode`, `FC`), not recursive generics; the runtime copies never meet — each client bundles its own.", - ], - [ - "hono", - "Only used behind first-party wrappers in `core/mcp/remote/node`; its generic router types are not related across the boundary.", - ], - [ - "jose", - "Consumed as flat function calls in `core/auth`; no generic type flows between installs.", - ], - [ - "@modelcontextprotocol/ext-apps", - "Plain interface/constant surface for the MCP Apps UI protocol; no generic instantiation to blow up.", - ], -]); - -/** Package names that are Node built-ins (with or without the `node:` prefix). */ -const BUILTINS = new Set([ - ...builtinModules, - ...builtinModules.map((m) => `node:${m}`), -]); - -// A bare package specifier: optional `@scope/`, then a name, then any subpath. -// Anchored so prose that happens to sit after the word `from` in a comment -// ("from cwd omitted") cannot be mistaken for an import. -const PACKAGE_SPECIFIER = /^(?:(@[^/\s]+)\/)?([^@/\s][^/\s]*)(?:\/.*)?$/; - -/** - * The bare package name a module specifier resolves to — `@scope/name` or - * `name`, with any subpath dropped (`zod/v4` → `zod`). Returns null for - * relative paths, built-ins, URLs, and anything not shaped like a specifier. - */ -export function packageNameOf(specifier) { - if (typeof specifier !== "string" || specifier === "") return null; - if (specifier.startsWith(".") || specifier.startsWith("/")) return null; - if (BUILTINS.has(specifier)) return null; - const m = PACKAGE_SPECIFIER.exec(specifier); - if (!m) return null; - const name = m[1] ? `${m[1]}/${m[2]}` : m[2]; - // A protocol-ish specifier (`node:test`, `file:`, `data:`) is not a package. - if (name.includes(":")) return null; - return name; -} - -// Specifiers are extracted with TypeScript's own `preProcessFile` rather than -// by regex (Copilot, #1962 — raised across three review rounds, and correctly). -// A regex scan gets both directions wrong: it *misses* valid syntax (an -// `import x = require(…)` in a `.cts`, an import-attributes argument, a comment -// between tokens — each a silent miss, so the package never enters the -// candidate set and its skew passes), and it *invents* names from prose, since -// `// adapted from "react"` is indistinguishable from an import to a pattern -// that can't tell code from a comment. Every widening of the regex traded one -// of those failures for the other. // -// `preProcessFile` is TypeScript's lightweight pre-parse scanner — not a full -// parse and no type checking — and it is exactly built for this: it returns -// every module specifier, handling all import forms, trivia, strings, and -// regex literals correctly, and it never sees a comment as code. +// **Empty today**, and that is a consequence of the #1965 derivation rather than +// a relaxation. The four former entries were all admitted under the old +// direct-import derivation, which asked only "is this name written in shared +// source and held at two versions somewhere" — a question two installs can +// answer yes to without any program ever seeing both copies: // -// typescript is resolved from `clients/web`, which already carries it; the root -// has no TS dependency of its own. The `createRequire` base is load-bearing — -// a bare `import("typescript")` would resolve relative to `scripts/`, not the -// cwd (the same reason `smoke-web-browser.mjs` resolves playwright this way). -let tsCache; -function typescript() { - if (!tsCache) { - const require_ = createRequire( - path.join(repoRoot, "clients", "web", "package.json"), - ); - try { - tsCache = require_("typescript"); - } catch (cause) { - // Fail with the cause, not a bare MODULE_NOT_FOUND: the realistic way to - // get here is a root install run with INSPECTOR_SKIP_CLIENT_INSTALL=1, - // which leaves `clients/web/node_modules` empty. Silently skipping the - // check instead would be worse — an unrun guard guards nothing. - throw new Error( - "verify:dep-lockstep — could not resolve `typescript` from clients/web. " + - "Run `npm install` at the repo root (the postinstall cascade installs each client); " + - "if you set INSPECTOR_SKIP_CLIENT_INSTALL=1, this guard cannot run.", - { cause }, - ); - } - } - return tsCache; -} - -/** - * The package(s) a `/// <reference types="x" />` directive can resolve to - * (Copilot, #1962). Such a directive pulls in declarations exactly like an - * import does, but TypeScript reports it separately from `importedFiles`, so - * reading only the latter would let a referenced package skew unseen. - * - * Both candidates are returned because the directive name is the *type* name, - * not the package: `node` resolves to `@types/node`, while a package shipping - * its own declarations resolves to itself. Returning both over-approximates, - * which is the safe direction — whichever isn't installed drops out. A scoped - * name mangles as `@scope/pkg` → `@types/scope__pkg`, TypeScript's convention. - */ -export function typeReferencePackageNames(directive) { - const name = packageNameOf(directive); - if (!name) return []; - const scoped = /^@([^/]+)\/(.+)$/.exec(name); - const typesName = scoped - ? `@types/${scoped[1]}__${scoped[2]}` - : `@types/${name}`; - return [name, typesName]; -} - -/** - * Every third-party package name whose declarations a blob of TypeScript source - * pulls in — via an import of any form, or a triple-slash type reference. - * Over-approximating is safe (a name absent from every lockfile contributes - * nothing downstream — `@inspector/core` is a build-time alias, not a package, - * and drops out that way); under-approximating is not, since a missed package - * never enters the candidate set and its skew would pass silently. - */ -export function importedPackageNames(source) { - const names = new Set(); - // (source, readImportFiles, detectJavaScriptImports) — the latter two make it - // report `require(…)` and dynamic imports as well as static ones. - const { importedFiles, typeReferenceDirectives } = - typescript().preProcessFile(source, true, true); - for (const { fileName } of importedFiles) { - const name = packageNameOf(fileName); - if (name) names.add(name); - } - for (const { fileName } of typeReferenceDirectives ?? []) - for (const name of typeReferencePackageNames(fileName)) names.add(name); - return names; -} +// • `jose` and `@modelcontextprotocol/ext-apps` are declared only at the root +// since #1970, so neither can skew at all now. +// • `react` ships no types of its own, so what lands in a program is +// `@types/react`, and it lands from a single install; the `react` package's +// own files never enter one. `hono` likewise resolves from one install per +// program (`clients/web` in web's node project). +// +// If any of them ever does reach one program from two installs, the guard fails +// and forces the decision then — with the actual version pair in hand, which is +// a better basis for a rationale than a pre-emptive entry. +const TOLERATED_SKEW = new Map(); /** * Top-level installed versions of every package in a parsed lockfile, keyed by * package name. Only `node_modules/<pkg>` entries count — a *nested* * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside - * one install, which is routine and not what this guard is about. + * one install, which is routine and not what this guard is about (the same + * asymmetry `classifyModulePath` applies to a resolved path). */ export function topLevelLockVersions(lock) { const versions = new Map(); @@ -311,84 +190,124 @@ export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { }; } -// The TypeScript extensions the shared trees can hold. Deliberately the same -// four `verify:format-coverage` and `verify:typecheck-coverage` gate on: a -// `.mts`/`.cts` under `core/` or `test-servers/src` is typechecked like any -// other source, so its imports must reach the candidate set too. None exist -// under those trees today, which is exactly why omitting them would go -// unnoticed until a new shared dependency arrived through one and skewed. -const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"]; - /** - * Whether a repo-relative path is shared first-party TypeScript — under one of - * the shared trees, or one of the individually-named shared files. + * The tsconfig projects whose programs a client contributes, from its own + * `package.json` scripts and its root `tsconfig.json` `references` — the same + * two enrollment paths `verify:typecheck-coverage` uses, so the two guards + * measure the same programs. + * + * A `neutered` project (its `typecheck` command carries `--noCheck` / + * `--listFilesOnly`) counts here even though its sibling guard rejects it: + * whether that pass type-checks is that guard's question, while this one only + * asks what the program *resolves*, and a program still resolves its imports + * either way. Dropping them would shrink what this guard measures on the + * strength of a defect the other guard is already failing on. */ -export function isSharedSourceFile(file) { - if (SHARED_SOURCE_FILES.includes(file)) return true; - if (!SOURCE_EXTENSIONS.some((ext) => file.endsWith(ext))) return false; - // Anchored on a path boundary so a sibling whose name merely starts with a - // shared dir (`core-internal/`, `test-servers/src-legacy/`) isn't swept in. - return SHARED_SOURCE_DIRS.some((dir) => file.startsWith(`${dir}/`)); +export function clientProjects(scripts, references) { + if (typeof scripts?.typecheck === "string") { + const { projects, neutered } = typecheckProjects(scripts); + return [...projects, ...neutered.map((n) => n.project)]; + } + return references; } /** - * Which configured shared sources contributed no file to `files`. Each dir and - * each individually-named file must be represented; an aggregate count can't - * see one of them going missing, because the others keep the total nonzero. + * The `clients/*` directories that are real installs — one with a + * `package.json`. Enrolment is by manifest, NOT by the presence of a lockfile + * (Copilot, #1962): filtering on the lockfile made a missing one silently drop + * that install from the comparison. */ -export function sourcesWithNoFiles( - files, - dirs = SHARED_SOURCE_DIRS, - named = SHARED_SOURCE_FILES, -) { - const missingDirs = dirs.filter( - (dir) => !files.some((f) => f.startsWith(`${dir}/`)), - ); - const missingNamed = named.filter((name) => !files.includes(name)); - return [...missingDirs, ...missingNamed]; -} - -/** Tracked TypeScript files under the shared first-party source trees. */ -function sharedSourceFiles() { - const out = execFileSync( - "git", - [ - "ls-files", - "--", - ...SHARED_SOURCE_DIRS.map((d) => `${d}/**`), - ...SHARED_SOURCE_FILES, - ], - { cwd: repoRoot, encoding: "utf8" }, - ); - return out.split("\n").filter(isSharedSourceFile); +function clientDirs() { + const dir = path.join(repoRoot, "clients"); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => `clients/${e.name}`) + .filter((rel) => existsSync(path.join(repoRoot, rel, "package.json"))) + .sort(); } /** - * The installs to compare: the repo root plus every `clients/*` that carries a - * lockfile. Discovered from disk rather than listed, so a new client is covered - * without editing this guard (the same enrollment style as - * `verify:typecheck-coverage`). + * The installs to compare: the repo root plus every `clients/*` install. + * Discovered from disk rather than listed, so a new client is covered without + * editing this guard (the same enrollment style as `verify:typecheck-coverage`). + * The root is always enrolled: it is this repo, so its manifest is a given. */ function installDirs() { - const clientsDir = path.join(repoRoot, "clients"); - const clients = existsSync(clientsDir) - ? readdirSync(clientsDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => `clients/${e.name}`) - .sort() - : []; - // Enrolment is by `package.json` — an install we are meant to compare — - // NOT by the presence of a lockfile (Copilot, #1962). Filtering on the - // lockfile made a missing one silently drop that install from the - // comparison; for the root, the install every shared source resolves from, - // that meant the guard could report success from client locks alone. A - // missing lockfile is now a loud failure in `main`, not an absent row. The - // root is always enrolled: it is this repo, so its manifest is a given. - return ["."].concat( - clients.filter((dir) => - existsSync(path.join(repoRoot, dir, "package.json")), - ), - ); + return ["."].concat(clientDirs()); +} + +/** + * List every client program and reduce it to the packages that reach one program + * from two installs. Returns `{ found, programs, problems }` — `found` is the + * {@link crossInstallPackages} map, `programs` the labels listed (for the + * success line), `problems` any reason the measurement itself can't be trusted. + * + * A program that resolves NO installed file at all is a problem, not an empty + * result: every real program pulls in at least the toolchain's own `.d.ts` from + * some `node_modules`, so an empty listing means `tsc` never ran (a missing + * install) or the config is broken. Treating it as "no candidates here" is the + * gate failing open. + */ +function deriveCandidates() { + const problems = []; + const programs = []; + for (const clientDir of clientDirs()) { + let scripts; + try { + scripts = JSON.parse( + readFileSync(path.join(repoRoot, clientDir, "package.json"), "utf8"), + ).scripts; + } catch (cause) { + throw new Error( + `verify:dep-lockstep — could not parse ${clientDir}/package.json.`, + { cause }, + ); + } + const projects = clientProjects( + scripts, + clientTsconfigReferences(clientDir), + ); + if (projects.length === 0) { + problems.push( + `${clientDir}: names no tsconfig project (no \`typecheck\` script, no \`tsconfig.json\` references) — none of its programs is measured.`, + ); + continue; + } + const leaves = new Set(); + for (const project of projects) + for (const leaf of resolveLeafProjects(clientDir, project)) + leaves.add(leaf); + for (const leaf of leaves) { + // A failed config is not a program: tsc still prints a file list, but it + // is whatever its fallback resolved rather than what the project declares, + // so measuring it would narrow the candidate set for a reason that has + // nothing to do with the dependency tree. + const error = projectListingError(clientDir, leaf); + if (error) { + problems.push( + `${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` exited non-zero — ${error.split("\n")[0]}`, + ); + continue; + } + const files = projectPackageFiles(clientDir, leaf); + if (files.size === 0) { + problems.push( + `${clientDir}: \`tsc -p ${leaf} --listFilesOnly\` resolved no installed file — the program could not be listed.`, + ); + continue; + } + programs.push({ label: `${clientDir}/${leaf}`, files }); + } + } + // No program at all means the enumeration itself broke (a moved `clients/` + // dir), not that there is nothing to check — and an empty candidate set from a + // broken enumeration is indistinguishable from a clean bill of health. + if (programs.length === 0 && problems.length === 0) + problems.push( + "found no client program to measure — every `clients/*` install must contribute at least one tsconfig project.", + ); + return { found: crossInstallPackages(programs), programs, problems }; } /** @@ -411,29 +330,20 @@ export function main() { process.exit(1); } - const files = sharedSourceFiles(); - // Per-source, not an aggregate count (Copilot, #1962): `vitest.shared.mts` - // alone keeps the total nonzero, so a moved or renamed `core/` would leave - // the guard checking a near-empty candidate set and passing. Every configured - // source must contribute, or the enumeration is broken. - const empty = sourcesWithNoFiles(files); - if (empty.length > 0) { + const { found, programs, problems } = deriveCandidates(); + if (problems.length > 0) { console.error( - `verify:dep-lockstep — ${empty.length} configured shared source(s) matched no tracked file:\n`, + `verify:dep-lockstep — ${problems.length} program(s) could not be measured:\n`, ); - for (const source of empty) console.error(` ${source}`); + for (const problem of problems) console.error(` ${problem}`); console.error( - "\nThe guard would derive its candidates from an incomplete set and pass on skew it should catch." + - "\nA path was moved or renamed — fix SHARED_SOURCE_DIRS / SHARED_SOURCE_FILES in this file.", + "\nThe candidate set is derived from what each `tsc` program resolves, so an unmeasured program" + + "\nmeans real skew could pass unseen. Run `npm install` at the repo root (the postinstall cascade" + + "\ninstalls each client), and see `verify:typecheck-coverage` for the tsconfig-project enrollment rules.", ); process.exit(1); } - - const candidates = new Set(); - for (const file of files) { - const source = readFileSync(path.join(repoRoot, file), "utf8"); - for (const name of importedPackageNames(source)) candidates.add(name); - } + const candidates = new Set(found.keys()); const dirs = installDirs(); @@ -510,12 +420,15 @@ export function main() { ); for (const { dir, version } of holders) console.error(` ${version} (${dir})`); + // Which program saw both copies is the whole reason the package is a + // candidate — without it the reader has no way to check the claim. + for (const label of [...found.get(name).programs].sort()) + console.error(` seen in ${label}`); } - const shared = [...SHARED_SOURCE_DIRS, ...SHARED_SOURCE_FILES].join(", "); console.error( - "\nThese packages' types are compiled into a single `tsc` program from two installs" + - `\n(${shared} resolve from the root, a client's own sources from the client),` + - "\nso a version skew makes TypeScript relate two structurally-distinct copies of the same" + + "\nEach of these reaches a single `tsc` program from two installs (a client's own sources resolve" + + "\nfrom the client install, the shared `core/` + `test-servers/src` they pull in resolve from the" + + "\nroot), so a version skew makes TypeScript relate two structurally-distinct copies of the same" + "\ntype. For a recursive-generic surface like zod that is what exhausted the tsc heap in #1896.", ); console.error( @@ -534,7 +447,8 @@ export function main() { const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; console.log( - `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note}.`, + `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note} ` + + `(derived from ${programs.length} tsc programs).`, ); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 55ca3b6f..25a643ab 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -3,258 +3,61 @@ // change that relaxes one is visible as a deleted assertion rather than a quiet // behavior shift. Run via `npm run test:scripts` (node:test; the root has no // vitest harness). +// +// The candidate derivation itself lives in `lib/tsc-program.mjs` (shared with +// `verify:typecheck-coverage` since #1965) and is covered by +// `lib/tsc-program.test.mjs`; what stays here is the lockfile comparison and the +// client-enrollment rule this guard owns. import { test } from "node:test"; import assert from "node:assert/strict"; import { + clientProjects, findSkew, hasReadableLockShape, - importedPackageNames, - isSharedSourceFile, majorOf, - packageNameOf, partitionSkew, - sourcesWithNoFiles, topLevelLockVersions, - typeReferencePackageNames, } from "./verify-dep-lockstep.mjs"; -test("isSharedSourceFile: all four TS extensions, not just .ts/.tsx", () => { - // `.mts`/`.cts` are gated by `verify:format-coverage` and - // `verify:typecheck-coverage` too. None exist under the shared trees today, - // so dropping them here would go unnoticed until a new shared dependency - // arrived through one — the skew this guard exists to catch (Copilot, #1962). - for (const ext of [".ts", ".tsx", ".mts", ".cts"]) { - assert.equal(isSharedSourceFile(`core/mcp/thing${ext}`), true, ext); - assert.equal( - isSharedSourceFile(`test-servers/src/thing${ext}`), - true, - `test-servers ${ext}`, - ); - } -}); - -test("isSharedSourceFile: non-TS files and other trees are excluded", () => { - const rejected = [ - "core/README.md", // not TypeScript - "core/mcp/data.json", - "clients/web/src/App.tsx", // a client's own sources resolve from the client - "scripts/verify-dep-lockstep.mjs", - "test-servers/configs/modern-http.json", - // Path-boundary anchoring: a sibling dir whose name merely starts with a - // shared dir's name must not be swept in. - "core-internal/thing.ts", - "test-servers/src-legacy/thing.ts", - ]; - for (const file of rejected) - assert.equal(isSharedSourceFile(file), false, file); -}); - -test("sourcesWithNoFiles: each configured source must contribute (Copilot, #1962)", () => { - const dirs = ["core", "test-servers/src"]; - const named = ["vitest.shared.mts"]; - const complete = [ - "core/mcp/a.ts", - "test-servers/src/b.ts", - "vitest.shared.mts", - ]; - assert.deepEqual(sourcesWithNoFiles(complete, dirs, named), []); - - // The failure an aggregate count can't see: `core/` moved, but the other two - // sources keep `files.length` nonzero, so the guard would derive candidates - // from an incomplete set and pass on skew it should catch. +test("clientProjects: a `typecheck` script's projects win over references", () => { + // Both enrollment paths exist (cli/tui/launcher declare `typecheck`, + // `clients/web` is a `tsc -b` solution) and this guard must measure the same + // programs as `verify:typecheck-coverage`, which prefers the script. assert.deepEqual( - sourcesWithNoFiles( - ["test-servers/src/b.ts", "vitest.shared.mts"], - dirs, - named, + clientProjects( + { + typecheck: "tsc --noEmit -p tsconfig.json && tsc -p tsconfig.test.json", + }, + ["./ignored.json"], ), - ["core"], + ["tsconfig.json", "tsconfig.test.json"], ); +}); + +test("clientProjects: a reference client is measured through its references", () => { assert.deepEqual( - sourcesWithNoFiles(["core/mcp/a.ts", "test-servers/src/b.ts"], dirs, named), - ["vitest.shared.mts"], + clientProjects({ build: "tsc -b && vite build" }, ["./a.json"]), + ["./a.json"], ); - assert.deepEqual(sourcesWithNoFiles([], dirs, named), [ - "core", - "test-servers/src", - "vitest.shared.mts", - ]); + // Neither path available — the caller reports it rather than measuring nothing. + assert.deepEqual(clientProjects({ build: "vite build" }, []), []); }); -test("sourcesWithNoFiles: a prefix sibling does not vouch for a dir", () => { - // `core-internal/` starts with `core` but is not it — the boundary check has - // to be on a path separator, or a renamed dir would look present. - assert.deepEqual(sourcesWithNoFiles(["core-internal/a.ts"], ["core"], []), [ - "core", - ]); -}); - -test("packageNameOf: bare names, scopes, and subpaths", () => { - const cases = [ - ["zod", "zod"], - ["zod/v4", "zod"], // subpath dropped — one package, one version - ["@modelcontextprotocol/client", "@modelcontextprotocol/client"], - ["@modelcontextprotocol/client/core", "@modelcontextprotocol/client"], - ["react-dom/client", "react-dom"], - ]; - for (const [input, expected] of cases) - assert.equal(packageNameOf(input), expected, input); -}); - -test("packageNameOf: non-packages are rejected", () => { - // Relative/absolute paths, built-ins with and without the `node:` prefix, - // protocol specifiers, and prose that follows the word `from` in a comment. - const rejected = [ - "./foo", - "../core/mcp", - "/abs/path", - "fs", - "path", - "node:crypto", - "node:test", - "file:", - "data:text/plain,x", - "cwd omitted", - "", - ]; - for (const input of rejected) - assert.equal(packageNameOf(input), null, JSON.stringify(input)); - assert.equal(packageNameOf(undefined), null); -}); - -test("importedPackageNames: CommonJS and awkward dynamic-import forms (Copilot, #1962)", () => { - // Under-approximating is the dangerous direction: a package the scan misses - // never enters the candidate set, so its skew passes the guard silently. - // `.cts` sources in the shared trees use `import x = require(…)` as ordinary - // syntax, and a dynamic import may carry import attributes or a static - // template literal — none of which the original three patterns matched. - const source = ` - import express = require("express"); - const yaml = require("yaml"); - const a = await import("undici", { with: { type: "json" } }); - const b = await import(\`jose\`); - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "jose", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: comment trivia between tokens (Copilot, #1962)", () => { - // TypeScript allows a comment anywhere whitespace is legal, so all of these - // are valid imports. Missing one is the dangerous direction: the package - // never enters the candidate set and its skew passes the guard silently. - const source = ` - import { a } from /* explanation */ "express"; - const b = await import(/* webpackIgnore: true */ "undici"); - const c = require(/* lazy */ "yaml"); - import /* side effect */ "pino"; - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "pino", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: line-comment trivia, not just block (Copilot, #1962)", () => { - // `//` runs to end-of-line and is legal in every position a block comment is, - // so a specifier can sit on the next line and these are still valid imports. - const source = [ - "import { a } from // reason", - ' "express";', - "const b = await import(// lazy", - ' "undici");', - "const c = require(// lazy", - ' "yaml");', - ].join("\n"); - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "express", - "undici", - "yaml", - ]); -}); - -test("importedPackageNames: static, side-effect, and dynamic forms; builtins and relatives dropped", () => { - const source = ` - import { z } from "zod/v4"; - export type { Foo } from '@modelcontextprotocol/core'; - import "./side-effect.css"; - import "pino"; - const mod = await import("chokidar"); - import fs from "node:fs"; - import { helper } from "../local/helper"; - `; - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "@modelcontextprotocol/core", - "chokidar", - "pino", - "zod", - ]); -}); - -test("importedPackageNames: triple-slash type references count (Copilot, #1962)", () => { - // A `/// <reference types="x" />` pulls in declarations exactly like an - // import, but TypeScript reports it in `typeReferenceDirectives`, not - // `importedFiles` — so reading only the latter let a referenced package skew - // unseen. `path` references name a file, not a package, and are ignored. - const source = [ - '/// <reference types="node" />', - '/// <reference types="express" />', - '/// <reference path="./local.d.ts" />', - 'import { z } from "zod";', - ].join("\n"); - assert.deepEqual([...importedPackageNames(source)].sort(), [ - "@types/express", - "@types/node", - "express", - "node", - "zod", - ]); -}); - -test("typeReferencePackageNames: both the bare and the @types form (Copilot, #1962)", () => { - // The directive names a *type*, not a package: `node` resolves to - // `@types/node`, while a package shipping its own declarations resolves to - // itself. Returning both over-approximates, the safe direction — whichever - // isn't installed drops out downstream. - assert.deepEqual(typeReferencePackageNames("node"), ["node", "@types/node"]); - // Scoped names mangle with a double underscore, TypeScript's convention. - assert.deepEqual(typeReferencePackageNames("@scope/pkg"), [ - "@scope/pkg", - "@types/scope__pkg", - ]); - assert.deepEqual(typeReferencePackageNames("./relative"), []); - assert.deepEqual(typeReferencePackageNames(""), []); -}); - -test("importedPackageNames: prose in comments never becomes a package (Copilot, #1962)", () => { - // The regex scan this replaced could not tell code from a comment, so - // `// adapted from "react"` added `react` to the candidate set — and if that - // installed package were skewed, an unrelated comment would fail `validate`. - // These use REAL package names, which is the case the old prose test missed: - // it only passed because `cwd omitted` isn't a valid package name. - const source = ` - // adapted from "react" - /** Mirrors the behavior of "express", see require("yaml") below. */ - /** The excluded set derived from \\\`hono\\\`-style paths. */ - // const disabled = await import("undici"); - import { z } from "zod"; - `; - assert.deepEqual([...importedPackageNames(source)], ["zod"]); -}); - -test("importedPackageNames: a specifier inside a string literal is not an import", () => { - const source = ` - const msg = 'run require("chokidar") to load it'; - const re = /"jose"/; - import { z } from "zod"; - `; - assert.deepEqual([...importedPackageNames(source)], ["zod"]); +test("clientProjects: a NEUTERED project still contributes its program (#1965)", () => { + // `--noCheck` stops that pass type-checking, which is the sibling guard's + // complaint; the program still resolves its imports, and dropping it here + // would shrink what THIS guard measures on the strength of that other defect. + assert.deepEqual( + clientProjects( + { + typecheck: + "tsc -p tsconfig.json --noCheck && tsc -p tsconfig.test.json", + }, + [], + ).sort(), + ["tsconfig.json", "tsconfig.test.json"], + ); }); test("topLevelLockVersions: nested duplicates are ignored", () => { @@ -479,13 +282,3 @@ test("majorOf: prerelease and build metadata are irrelevant", () => { for (const bad of ["next", "", undefined, null, "v4.4.3"]) assert.equal(majorOf(bad), null, JSON.stringify(bad)); }); - -test("isSharedSourceFile: individually-named shared files are included (Copilot, #1962)", () => { - // `vitest.shared.mts` is root-owned, imported by every client's vitest - // config, and already treated as shared by `verify:typecheck-coverage`. It - // imports only Node built-ins today, which is why omitting it would go - // unnoticed until a third-party import appeared there and skewed. - assert.equal(isSharedSourceFile("vitest.shared.mts"), true); - // Still anchored: a same-named file nested elsewhere is not the shared one. - assert.equal(isSharedSourceFile("clients/web/vitest.shared.mts"), false); -}); diff --git a/scripts/verify-typecheck-coverage.mjs b/scripts/verify-typecheck-coverage.mjs index 20d6ab2c..5293db6e 100644 --- a/scripts/verify-typecheck-coverage.mjs +++ b/scripts/verify-typecheck-coverage.mjs @@ -47,6 +47,18 @@ import { rootRunsClientValidate, tokenize, } from "./lib/npm-scripts.mjs"; +// The `tsc --listFilesOnly` machinery lives in `lib/` because +// `verify-dep-lockstep` measures the same programs for a different question +// (#1965) — one implementation, so the two guards can't disagree about what a +// program contains. +import { + clientTsconfigReferences, + isDisablingFlag, + isTsc, + projectSourceFiles, + resolveLeafProjects, + typecheckProjects, +} from "./lib/tsc-program.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -70,15 +82,6 @@ const EXEMPT = new Map(); export const isRequiredSource = (rel) => /\.(ts|tsx|mts|cts)$/.test(rel) && !/\.d\.(ts|mts|cts)$/.test(rel); -// Match `tsc` by token basename so a path-invoked binary (`node_modules/.bin/ -// tsc`, `./node_modules/.bin/tsc.cmd`) counts, not just the bare `tsc` token. -export const isTsc = (t) => /(?:^|[\\/])tsc(?:\.(?:cmd|exe|ps1))?$/.test(t); - -// A flag that makes a `tsc` pass list files without type-checking them (so it -// gates nothing). Case-insensitive — tsc's own option parsing is. Shared by the -// `typecheck`-script path and the reference (`tsc -b`) path. -export const isDisablingFlag = (t) => /^--(noCheck|listFilesOnly)$/i.test(t); - /** * Whether a tracked test file is matched by one of the `test:scripts` command's * globs. Delegates to node's own `path.matchesGlob` (22.5.0+; the repo floor is @@ -256,51 +259,6 @@ export function testScriptProblems(scripts, testFiles) { return problems; } -/** - * The `references` paths declared in the tsconfig at repo-relative `tsconfigRel` - * (a `tsc -b` solution config), or `[]` if it has none / isn't readable. Paths - * are as written (relative to that tsconfig's own directory). - */ -export function parseTsconfigReferences(raw) { - try { - // Tolerate JSONC — block AND line comments + trailing commas (tsconfig - // allows all; block comments are in fact the style of every other tsconfig - // here). Block comments are stripped first so a `//` inside one doesn't - // survive; a `//` inside a string value (e.g. an `https://` URL) is a - // theoretical false strip this guard's tsconfigs never hit. - const cfg = JSON.parse( - raw - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, "") - .replace(/,(\s*[}\]])/g, "$1"), - ); - return Array.isArray(cfg.references) - ? cfg.references.map((r) => r?.path).filter((p) => typeof p === "string") - : []; - } catch { - return []; - } -} - -export function tsconfigReferences(tsconfigRel) { - try { - return parseTsconfigReferences( - readFileSync(path.join(repoRoot, tsconfigRel), "utf8"), - ); - } catch { - return []; // unreadable file (e.g. a directory / missing path) - } -} - -/** - * The `references` in a client's root `tsconfig.json`. Non-empty for a `tsc -b` - * client (like `clients/web`, which has no `typecheck` script) — this guard - * enrolls it through these instead of exempting the whole tree. - */ -export function clientTsconfigReferences(clientDir) { - return tsconfigReferences(path.posix.join(clientDir, "tsconfig.json")); -} - /** * How the client's `validate` runs `tsc -b` (the pass that typechecks a * reference client): `"ok"` (a real `tsc -b`), `"neutered"` (a `tsc -b` carrying @@ -388,64 +346,6 @@ function nodeClients() { return { clients, problems }; } -/** - * The tsconfig projects a client's `typecheck` names, harvested from **every** - * script reachable from `typecheck` (not just the one string) so a delegating - * `typecheck` (`npm run typecheck:src && …`) still counts — matching how - * `verify-format-coverage.mjs` harvests globs across reachable scripts. Splits - * each script on `&&`/`||`/`;` so a flag on one command doesn't leak onto - * another. Each `tsc` command's project comes from `-p`/`--project` (or a - * `-b`/`--build` path); a `tsc` command with **no** project flag resolves the - * implicit `./tsconfig.json` (tsc's own default), so that idiomatic form counts - * too. Returns `{ projects, neutered }`: `neutered` names any project whose own - * command carries `--noCheck`/`--nocheck` or `--listFilesOnly` (matched - * case-insensitively — tsc's option parsing is) — a pass that lists files - * without type-checking them, which would otherwise satisfy the guard while - * checking nothing. The config-file form (`noCheck` set in the tsconfig) is - * caught separately by {@link projectDisablesChecking}. - * - * A harvested `tsc -b` **solution config** (`"files": []` + `references`) lists - * nothing itself; {@link projectFiles} expands it to its references, so this - * form is measured whether it reaches here (a `typecheck: "tsc -b"`) or the - * dedicated reference path (`clients/web`, which declares no `typecheck`). - * - * Minor limitations, all unreachable with the plain `-p --noEmit` passes here: - * the implicit-`./tsconfig.json` fallback assumes **no file operands** (`tsc - * <file>` ignores the config and checks only that file, but would be credited - * the whole config's file list); the `--noCheck`/`--listFilesOnly` detection - * ignores a following boolean, so the contrived explicit `--noCheck false` - * (checking *on*) is still treated as disabling; and the `&&`/`||`/`;` split - * runs before tokenizing, so a quoted operator inside an arg would split - * mid-token (project paths carry none of those). - */ -export function typecheckProjects(scripts) { - const projects = []; - const neutered = []; - const isFlag = (t) => t.startsWith("-"); - const isProjectFlag = (t) => ["-p", "--project", "-b", "--build"].includes(t); - for (const name of reachableScripts(scripts, "typecheck")) { - const cmd = scripts?.[name]; - if (typeof cmd !== "string") continue; - for (const segment of cmd.split(/&&|\|\||;/)) { - const tokens = tokenize(segment); - if (!tokens.some(isTsc)) continue; // only tsc commands name projects - const disabling = tokens.find(isDisablingFlag); - // A project path follows `-p`/`--project`/`-b`/`--build`; a tsc command - // with none uses the implicit `./tsconfig.json` (tsc's own default). - const named = []; - for (let i = 0; i < tokens.length; i++) - if (isProjectFlag(tokens[i]) && tokens[i + 1] && !isFlag(tokens[i + 1])) - named.push(tokens[i + 1]); - if (named.length === 0) named.push("tsconfig.json"); - for (const project of named) { - if (disabling) neutered.push({ project, flag: disabling }); - else projects.push(project); - } - } - } - return { projects, neutered }; -} - /** * Whether the tsconfig `project` sets `noCheck` (which disables type-checking as * thoroughly as the CLI flag, but can't be seen in the `typecheck` script @@ -466,116 +366,11 @@ function projectDisablesChecking(clientDir, project) { } } -/** - * Repo-relative POSIX paths of the files ONE project (no reference expansion) - * typechecks. Absolute paths outside the repo root (lib.d.ts) and anything under - * `node_modules` are dropped; the aliased `core/` + `test-servers/` sources stay - * in the set but are harmless — the set is only ever queried with client-relative - * paths. Cached: `resolveLeafProjects` and `projectFiles` both list a project. - */ -const rawFilesCache = new Map(); -function rawProjectFiles(clientDir, project) { - const key = `${clientDir}|${project}`; - const cached = rawFilesCache.get(key); - if (cached) return cached; - const absClient = path.join(repoRoot, clientDir); - let stdout; - try { - stdout = execFileSync( - "npx", - ["--no-install", "tsc", "-p", project, "--listFilesOnly"], - { cwd: absClient, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ); - } catch (err) { - // `--listFilesOnly` doesn't type-check, but a config error (an unreadable or - // malformed tsconfig) still exits non-zero while printing the resolved file - // list; keep stdout so a broken config doesn't mask a coverage gap. Echo the - // diagnostic — since this guard runs before any client's own `typecheck`, - // it's the first place a bad `-p` config surfaces, and without the reason - // the resulting "file in no project" report is misleading. tsc prints config - // errors (`error TS…`) to stdout, so scan both streams for them. - stdout = typeof err.stdout === "string" ? err.stdout : ""; - const streams = - stdout + "\n" + (typeof err.stderr === "string" ? err.stderr : ""); - const diagnostic = streams - .split("\n") - .filter((l) => /error TS\d+/.test(l)) - .join("\n") - .trim(); - console.warn( - `verify:typecheck-coverage — \`tsc -p ${project}\` (in ${clientDir}) exited non-zero:\n${diagnostic || "(no diagnostic captured)"}\n`, - ); - } - const covered = new Set(); - for (const line of stdout.split("\n")) { - const abs = line.trim(); - if (!abs) continue; - const rel = path.relative(repoRoot, abs); - if (rel.startsWith("..") || rel.includes("node_modules")) continue; - covered.add(rel.split(path.sep).join("/")); - } - rawFilesCache.set(key, covered); - return covered; -} - -/** - * The repo-relative tsconfig FILE a `clientDir`-relative `project` entry names. - * A directory-form entry (`{ "path": "./packages/a" }`, or `tsc -p src`) means - * `<dir>/tsconfig.json` — tsc's own rule. - */ -export function projectConfigFile(clientDir, project) { - const projectRel = path.posix.join(clientDir, project); - return projectRel.endsWith(".json") - ? projectRel - : path.posix.join(projectRel, "tsconfig.json"); -} - -/** - * A `references` entry `ref` (written relative to `fromConfigFile`'s own - * directory) as a `clientDir`-relative project path, the form the rest of the - * graph walk uses. - */ -export function refToProject(clientDir, fromConfigFile, ref) { - return path.posix.relative( - clientDir, - path.posix.join(path.posix.dirname(fromConfigFile), ref), - ); -} - -/** - * The leaf tsconfig projects `project` resolves to (paths relative to - * `clientDir`): itself if it lists files (or has no `references`), else its - * `references` expanded recursively. A `tsc -b` **solution config** (`{"files": - * [], "references": […]}`) lists nothing under `--listFilesOnly`, so this is how - * it's reduced to the real projects — and doing it here (not just inside - * `projectFiles`) is what lets BOTH coverage and the non-inertness check follow - * the same graph, so a `noCheck` in a *referenced* project is caught no matter - * which enrollment path harvested the solution. - */ -export function resolveLeafProjects(clientDir, project, seen = new Set()) { - if (seen.has(project)) return []; - seen.add(project); - // Lists files → a real leaf. (An empty set is a solution config, or a config - // that errored — either way the reference expansion below is the right next - // step: a broken config yields no references too.) - if (rawProjectFiles(clientDir, project).size > 0) return [project]; - const configFile = projectConfigFile(clientDir, project); - const refs = tsconfigReferences(configFile); - if (refs.length === 0) return [project]; // no files, no refs — itself - return refs.flatMap((ref) => - resolveLeafProjects( - clientDir, - refToProject(clientDir, configFile, ref), - seen, - ), - ); -} - /** Repo-relative files a project covers, following a solution config's references. */ function projectFiles(clientDir, project) { const covered = new Set(); for (const leaf of resolveLeafProjects(clientDir, project)) - for (const f of rawProjectFiles(clientDir, leaf)) covered.add(f); + for (const f of projectSourceFiles(clientDir, leaf)) covered.add(f); return covered; } diff --git a/scripts/verify-typecheck-coverage.test.mjs b/scripts/verify-typecheck-coverage.test.mjs index 22151c25..d2cca80a 100644 --- a/scripts/verify-typecheck-coverage.test.mjs +++ b/scripts/verify-typecheck-coverage.test.mjs @@ -2,24 +2,22 @@ // module exposes these without running the guard (its execution is behind // `main()`, called only when the file is run directly). Each case pins a rule a // #1799 review round found. Run via `npm run test:scripts`. +// +// The `tsc`-script and tsconfig-graph parsers this guard used to own moved to +// `lib/tsc-program.mjs` when `verify:dep-lockstep` began measuring the same +// programs (#1965); their cases moved to `lib/tsc-program.test.mjs` with them. import { test } from "node:test"; import assert from "node:assert/strict"; import { matchesTestGlob, - isDisablingFlag, isRequiredSource, - isTsc, - parseTsconfigReferences, - projectConfigFile, - refToProject, integrityAdvice, isScriptsTestFile, testScriptGlobs, testScriptNarrowingFlags, testScriptProblems, tscBuildStatus, - typecheckProjects, } from "./verify-typecheck-coverage.mjs"; test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => { @@ -29,83 +27,6 @@ test("isRequiredSource: TS extensions, ambient .d.ts excluded (r7)", () => { assert.ok(!isRequiredSource(f), f); }); -test("isTsc: matches by basename incl. path-invoked (r18 regression)", () => { - for (const t of [ - "tsc", - "node_modules/.bin/tsc", - "./node_modules/.bin/tsc.cmd", - ]) - assert.ok(isTsc(t), t); - for (const t of ["vitest", "prettier", "tscx", "atsc"]) - assert.ok(!isTsc(t), t); -}); - -test("isDisablingFlag: case-insensitive (r18)", () => { - for (const t of [ - "--noCheck", - "--nocheck", - "--listFilesOnly", - "--LISTFILESONLY", - ]) - assert.ok(isDisablingFlag(t), t); - for (const t of ["--noEmit", "-p", "--project", "noCheck"]) - assert.ok(!isDisablingFlag(t), t); -}); - -test("typecheckProjects: harvests -p / --project / -b, implicit tsconfig.json (r13)", () => { - const { projects, neutered } = typecheckProjects({ - typecheck: - "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.test.json", - }); - assert.deepEqual(projects, ["tsconfig.json", "tsconfig.test.json"]); - assert.equal(neutered.length, 0); - - // A bare `tsc` (no project flag) resolves the implicit ./tsconfig.json. - assert.deepEqual(typecheckProjects({ typecheck: "tsc --noEmit" }).projects, [ - "tsconfig.json", - ]); - - // Path-invoked binary still counts (r18). - assert.deepEqual( - typecheckProjects({ typecheck: "node_modules/.bin/tsc -p tsconfig.json" }) - .projects, - ["tsconfig.json"], - ); - - // A quoted project path (r17). - assert.deepEqual( - typecheckProjects({ typecheck: `tsc -p "tsconfig.test.json"` }).projects, - ["tsconfig.test.json"], - ); - - // `--project` long form, and `-b`/`--build` project paths (r13). - const proj = (cmd) => typecheckProjects({ typecheck: cmd }).projects; - assert.deepEqual(proj("tsc --noEmit --project tsconfig.json"), [ - "tsconfig.json", - ]); - assert.deepEqual(proj("tsc -b tsconfig.json"), ["tsconfig.json"]); - assert.deepEqual(proj("tsc --build tsconfig.json"), ["tsconfig.json"]); - assert.deepEqual(proj("tsc -b"), ["tsconfig.json"]); // implicit fallback -}); - -test("typecheckProjects: neutered by --noCheck / --listFilesOnly (r10)", () => { - const { projects, neutered } = typecheckProjects({ - typecheck: - "tsc --noEmit -p tsconfig.json --noCheck && tsc --noEmit -p tsconfig.test.json", - }); - assert.deepEqual(projects, ["tsconfig.test.json"]); - assert.deepEqual(neutered, [{ project: "tsconfig.json", flag: "--noCheck" }]); -}); - -test("typecheckProjects: delegating typecheck, ignores non-tsc segments (r15)", () => { - const { projects } = typecheckProjects({ - typecheck: "npm run typecheck:src && npm run typecheck:test", - "typecheck:src": "tsc --noEmit -p tsconfig.json", - "typecheck:test": "tsc --noEmit --project tsconfig.test.json", - }); - assert.deepEqual(projects.sort(), ["tsconfig.json", "tsconfig.test.json"]); -}); - test("tscBuildStatus: ok / neutered / none (r25)", () => { const status = (build) => tscBuildStatus({ validate: "npm run build", build }); @@ -116,22 +37,6 @@ test("tscBuildStatus: ok / neutered / none (r25)", () => { assert.equal(status("tsc --noEmit -p tsconfig.json"), "none"); // -b required }); -test("parseTsconfigReferences: JSONC tolerance (r17-nit2 block comments)", () => { - const refs = (raw) => parseTsconfigReferences(raw); - assert.deepEqual(refs('{ "references": [{ "path": "./a" }] }'), ["./a"]); - assert.deepEqual( - refs('/* solution */\n{ "references": [{ "path": "./a" }] }'), - ["./a"], - ); - assert.deepEqual(refs('{ "references": [{ "path": "./a" }] } // trailing'), [ - "./a", - ]); - assert.deepEqual(refs('{ "references": [{ "path": "./a" },] }'), ["./a"]); // trailing comma - assert.deepEqual(refs('{ "files": [] }'), []); // no references - assert.deepEqual(refs("{ not json"), []); // malformed - assert.deepEqual(refs('{ "references": [{ "prepend": true }] }'), []); // no path -}); - test("matchesTestGlob: the guard's contract, not node's glob engine", () => { // Only the two properties the guard actually relies on — the rest of node's // glob semantics are node's to test, which is the point of delegating to it. @@ -386,42 +291,3 @@ test("integrityAdvice: typecheck footer only for typecheck issues (r35 finding 2 /Restore the `typecheck` wiring/, ); }); - -test("projectConfigFile: directory-form entry means <dir>/tsconfig.json (r26)", () => { - assert.equal( - projectConfigFile("clients/cli", "tsconfig.test.json"), - "clients/cli/tsconfig.test.json", - ); - assert.equal( - projectConfigFile("clients/cli", "packages/a"), - "clients/cli/packages/a/tsconfig.json", - ); - assert.equal( - projectConfigFile("clients/cli", "."), - "clients/cli/tsconfig.json", - ); -}); - -test("refToProject: refs resolve against the REFERRING config's dir (r26)", () => { - // A ref is relative to the tsconfig that declares it, not to clientDir. - assert.equal( - refToProject( - "clients/web", - "clients/web/tsconfig.json", - "./tsconfig.app.json", - ), - "tsconfig.app.json", - ); - assert.equal( - refToProject( - "clients/web", - "clients/web/sub/tsconfig.json", - "../other.json", - ), - "other.json", - ); - assert.equal( - refToProject("clients/web", "clients/web/sub/tsconfig.json", "./deep"), - "sub/deep", - ); -}); From a9b9a917e30eaf5ce4275f711581cc9ced419e38 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 12:35:38 -0400 Subject: [PATCH 169/941] chore(scripts): compare only the copies a program actually met MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the Copilot review of #1965, both real. 1. The candidates were flattened to package names before the version comparison, so a third install holding a different copy failed the guard even when no program loads that copy beside another — and the diagnostic named an install that never took part. `crossInstallPackages` now returns the co-occurrences themselves (name → program → install → entry paths) and `findSkew` compares per program, across only the installs that met there. 2. Folding a nested copy onto its outermost install lost the path that identifies which copy was loaded, and the version was then read from the install's top-level entry — absent or differently versioned, either way letting a real pair pass. `classifyModulePath` now also returns the `entryPath` npm keys the lockfile by, `topLevelLockVersions` becomes `lockVersionsByPath`, and each holder is priced from the entry the program resolved. A resolved copy with no lockfile entry is now a hard failure rather than a silently dropped holder. The failure output prints `in <program>` with each holder's install path beside its version, so a nested copy is distinguishable from a top-level one. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- README.md | 2 +- scripts/lib/tsc-program.mjs | 68 +++-- scripts/lib/tsc-program.test.mjs | 63 +++-- scripts/verify-dep-lockstep.main.test.mjs | 106 +++++++- scripts/verify-dep-lockstep.mjs | 132 ++++++--- scripts/verify-dep-lockstep.test.mjs | 310 +++++++++++++++------- 7 files changed, 495 insertions(+), 188 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eac73b7f..d07e84f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -694,7 +694,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **cli and tui now typecheck their `src` (#1689).** Their `build`/`test` run through esbuild (no type check), so each has a `typecheck` script folded into `validate`. Their `tsconfig.json` matches `clients/web/tsconfig.app.json`'s module/lib _resolution_ options — DOM lib, `moduleResolution: bundler`, and **no** `noUncheckedIndexedAccess` (web's app config does not extend `tsconfig.base`, so re-enabling it would surface `core/` issues web never gates) — so the imported `core/` sources are validated the same way web validates them. It does **not** mirror web's extra strictness flags (`noUnusedLocals`, `verbatimModuleSyntax`, ES2023 target, …), so cli/tui's own `src` is checked slightly more loosely than web's. `core/` itself still typechecks through web's `tsc -b`. - **The `__tests__` dirs are typechecked too (#1791).** The src-only `tsconfig.json` excludes `**/*.test.*`, so each of cli, tui, and launcher carries a **`tsconfig.test.json`** — extending the build config, `noEmit`, including `__tests__/**/*` (only the tests root the project; tsc pulls in the `src` they import, and the src-only config already validates all of `src` without the test-only aliases) and adding the test-only path aliases that resolve what vitest resolves via `vitest.shared.mts`. The alias set differs per client: **cli's is the widest** (`@modelcontextprotocol/inspector-test-server` → `test-servers/src`, the `@inspector/core/*` deep paths, express/vitest — cli is the only one importing the test-server package); **tui's** carries only the `@inspector/core/*` + react/vitest redirects; **launcher's** has **no** `paths` at all — it's a plain `rootDir: "."` sibling of the build config (whose `rootDir: ./src` is what rejects the tests). Each client's `typecheck` script runs **both** projects (`tsc -p tsconfig.json && tsc -p tsconfig.test.json`) so running it standalone means the same thing everywhere (launcher's `build` also `tsc`s `src`, but `typecheck` doesn't rely on that). cli additionally carries `@types/express` (devDep) so the transitively-aliased test-server source typechecks, mirroring `clients/web` (cli's `tsconfig.test.json` also names `test-servers/src/server-composable.ts` explicitly — a bin entry the barrel doesn't import, so nothing else gives it a tsc pass). The client **config files** are typechecked too: cli's/tui's (`vitest.config.ts`, `tsup.config.ts`, tui `dev.ts`) are folded into each src `tsconfig.json`'s `include`; launcher's `vitest.config.ts` goes in its `tsconfig.test.json` instead (again the `rootDir: ./src` reason). Note the gate checks mock **implementations and return types** (typing a `vi.fn<T>()` against a real signature keeps its `mockResolvedValue`/impl in sync) but **not** `toHaveBeenCalledWith(...)` arguments — vitest types those to accept anything regardless of the mock's type parameter. **`npm run verify:typecheck-coverage`** (`scripts/verify-typecheck-coverage.mjs`, run as the second step of `validate` right after `verify:format-coverage`) is the durable guard for this invariant: it runs each client's `typecheck` projects with `tsc --listFilesOnly`, unions them, and fails on any tracked `.ts`/`.tsx`/`.mts`/`.cts` that lands in no project — for every gated Node client, which it discovers from disk (each `clients/*` is enrolled through its `typecheck` script's projects, or — for a `tsc -b` client like `clients/web` with no `typecheck` script — through its `tsconfig.json` `references`), so a new client is covered without editing the guard — the typecheck analog of `verify:format-coverage`, since a project only reaches the files its `include` names plus their transitive imports, so a new top-level file (launcher especially, whose build `rootDir: ./src` rejects package-root files) can otherwise fall out silently. Like its sibling it also asserts the gate is _wired_ (each client's typecheck pass is reachable from its `validate` — its `typecheck` script for cli/tui/launcher, or a real `tsc -b` for web — and the root chain runs each client's `validate`), so it can't stay green while measuring a pass nothing invokes. It asserts the same of **`test:scripts`** — its own parser tests — on three axes: reachable from the root `validate`, a **non-empty** tracked `scripts/**/*.{test,spec}.*` set, and **every one of those files matched by a glob harvested across the scripts reachable from `test:scripts`** (so a delegating `test:scripts` still measures correctly). The third axis exists because `node --test` silently _skips_ a file its glob misses and still exits 0 — a rename to `*.spec.mjs` would shrink the suite with a green run. Beyond the clients it also covers, **deny-by-default**, the first-party TS no client owns — everything tracked outside `clients/*` (`test-servers/src/**`, the root `vitest.shared.mts`, **all of `core/`**, and any new top-level TS location) must land in the _global_ union of client projects (cli aliases the test-server source; web's enrolled projects include `core/`). So a `core` `*.tsx` web's `include` doesn't reach, or an unimported `test-servers/src` bin entry, can't ship uncompiled-but-unchecked. The one "listed but unchecked" tier the guard structurally can't see — a per-file `// @ts-nocheck` — is owned by a different gate: `@typescript-eslint/ban-ts-comment` rejects it across every surface (`lint:core`, `lint:shared`, and each client's `eslint .`). The guard's own pure parsers (`scripts/lib/npm-scripts.mjs` + the exported helpers of `verify-typecheck-coverage.mjs`, whose execution is behind a `main()` so importing it for tests doesn't run it) are **unit-tested** — `npm run test:scripts` (node's built-in `node --test`, in `validate`; the root has no vitest harness by design) runs table-driven cases, one per rule the guard's parsers encode, and the guard itself enforces that this stays wired (above). - The one CLI nuance: `clients/cli`'s out-of-process `e2e.test.ts` spawns the built binary, so its `test` **builds first** via `pretest` (`test-servers:build && build`). To avoid building it twice, `clients/cli`'s `validate` folds that in — it is `format:check && lint && typecheck && test` with **no** separate `build` step (the other clients, whose tests don't spawn their bundle, keep an explicit `build`). `validate:web`/`validate:tui`/`validate:launcher` are the uniform `format:check && lint && (typecheck &&) build && test`. (#1778, #1789, #1792) `clients/web`'s `format`/`format:check` covers `src`, `server`, `.storybook`, and its top-level configs (the uniform `*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` glob — `vite.config.ts`, `tsup.runner.config.ts`, `eslint.config.js`, …), not just `src`, so the Node backend, Storybook config, and Vite/build config are prettier-gated too; `clients/launcher`'s covers `src`, `__tests__`, `scripts`, and its top-level configs (the `*.` top-level glob is non-recursive, so each nested dir — `.storybook`, `scripts` — is named explicitly). The `verify:format-coverage` guard (#1792) enforces that this coverage stays complete. - - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions themselves still come from the committed lockfiles' **top-level** `node_modules/<pkg>` entries — a *nested* transitive duplicate inside one install is routine and deliberately ignored — and any candidate held at two versions fails, **deny-by-default**. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. + - **One version per install-crossing dependency (#1896).** Because v2 is not a workspace, the root and each `clients/*` carry their own `node_modules` — and a client's `tsconfig.test.json` compiles first-party sources that live *outside* the client (`test-servers/src`, `core/`), which resolve their dependencies from the **root** install while the client's own sources resolve from the client install. So the same package can appear **twice in one `tsc` program**. At the same version that duplication is harmless; on a skew, TypeScript must relate two structurally-distinct declarations of the same type. For a deeply recursive-generic surface that is exponential: zod `4.3.6` (root) against zod `4.4.3` (`clients/web`) made `clients/web`'s `tsc -b` exhaust the 4GB default heap outright via `TS2589 Type instantiation is excessively deep`, because every `@modelcontextprotocol/*` schema is built out of zod generics. **Raising the heap with `--max-old-space-size` hides this class rather than fixing it — align the versions instead.** `npm run verify:dep-lockstep` (`scripts/verify-dep-lockstep.mjs`, in `validate`) is the durable guard: it **derives** the candidate set from **what actually enters each `tsc` program** (#1965) — every client tsconfig project is listed with `tsc --listFilesOnly` through the shared `scripts/lib/tsc-program.mjs` helper (the same machinery `verify:typecheck-coverage` reads a program through, so the two guards can't disagree about what one contains), each resolved `node_modules` file is mapped to its owning install and package, and a package reaching **one** program from **two** installs is a candidate. That is precisely the set that can put two structurally-distinct copies of a type in front of one checker — and it needs no editing when a new dependency arrives. It replaced a derivation that read the packages the shared sources named *directly*, which could not see one whose declarations arrive only through another package's `.d.ts`: `@modelcontextprotocol/sdk` is never written in first-party code (the shared sources import the split `@modelcontextprotocol/client|core|…`) yet 16 of its `.d.ts` files land in `clients/web`'s test program, so a second copy under `clients/web/node_modules` skewed unseen. Two properties are worth knowing: a package present in two installs but reached from only one in a given program is correctly **not** a candidate, and TypeScript's package-identity redirect collapses two copies at the *same* name@version (so an aligned package's own transitive dependencies load once) — the redirect stops applying the moment they skew, which is exactly when the guard needs to see both. Versions still come from the committed lockfiles, but from the entry for the **exact install path the program resolved** (`node_modules/zod`, `node_modules/a/node_modules/zod`) rather than from the install's top-level entry: a *nested* duplicate inside one install is still not a candidate on its own — folding it onto its outermost install is what keeps the set small — but once a program has loaded one, pricing it from a top-level entry that may be absent or differently versioned would let a real pair pass (Copilot). Only the installs that actually **met in one program** are compared, so a third install's copy that no program loads beside another is not evidence of anything. Any co-occurrence whose copies disagree fails, **deny-by-default**; a resolved copy with no lockfile entry fails too, since the tree and the lockfile then disagree about what was loaded. The escape hatch is `TOLERATED_SKEW` in that file, an allowlist of *names* (not version pairs, so an ordinary patch float doesn't churn it), each entry carrying why that package's types can't blow up; it is **empty today** — the four names it used to carry (`react`, `hono`, `jose`, `@modelcontextprotocol/ext-apps`) were admitted under the old derivation and none is a candidate under this one, so each would be a rationale for a skew that cannot occur. **Being listed is not a blanket exemption** — it tolerates skew only *within a major version*, since a rationale about patch-level differences says nothing about a React 18-vs-19 split, where the type surface itself changes; a cross-major skew fails even for a listed package. **When bumping a dependency that the shared sources pull in, bump it in every install that declares it** — that's the root plus whichever clients list it, not all four unconditionally (launcher declares no zod, for instance, and a package absent from an install can't skew, so the guard ignores it there). Don't add a dependency to a client just to satisfy this. Its pure helpers are unit-tested via `test:scripts` (its own, plus `scripts/lib/tsc-program.test.mjs` for the shared derivation), and it vouches — with `verify:format-coverage` and `verify:typecheck-coverage` — that its siblings are still wired into `validate`. It runs the same `tsc --listFilesOnly` pass its sibling does, in its own process, costing ~14s: the listing is deliberately **not** cached to disk between the two, because a fingerprint that missed an input would make a guard measure a program that no longer exists and pass on a real miss. - **`npm run coverage`** is the per-file ≥90 gate and is now part of `npm run ci` — never treat it as optional before a push. It supersedes the old standalone `test:integration` step: web's `test:coverage` runs the `unit` **and** `integration` projects under v8 instrumentation, so `coverage` both enforces the ≥90 gate and exercises the same web integration paths CI covers. - **`smoke` is NOT part of `validate`** — it is included in `npm run ci`. It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app`, and contains **no build commands** — it assumes the cli/tui/launcher bundles already exist (a full `validate` builds them; `smoke:web` builds `clients/web/dist` on demand). CI runs `validate`, then the `coverage` gate (which also covers the web integration project), then `verify:build-gate` (the #1769 build gate — see below), then `smoke` (with Playwright chromium installed just before it, since `smoke:web:browser` needs it). GitHub CI runs this same chain as separate workflow steps, with the Storybook play-function tests last (see below). - `smoke:launcher` (`scripts/smoke-launcher.mjs`) runs the built launcher with `--help`, `--cli --help`, and `--tui --help`, asserting each exits 0 and prints that mode's usage banner (which also proves the launcher resolved and loaded the right client build). It's the cheap dispatch check before the heavier prod smokes below. diff --git a/README.md b/README.md index 0bca3291..cb57dd35 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Compares those against the committed lockfiles' top-level entries and **fails deny-by-default** on any skew not in the annotated `TOLERATED_SKEW` allowlist — empty today — and an allowlisted package is tolerated only *within a major version*. Runs in `validate`. +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | | `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | diff --git a/scripts/lib/tsc-program.mjs b/scripts/lib/tsc-program.mjs index 189c13df..98a678e6 100644 --- a/scripts/lib/tsc-program.mjs +++ b/scripts/lib/tsc-program.mjs @@ -302,18 +302,26 @@ export function projectPackageFiles(clientDir, project) { } /** - * The install a repo-relative `node_modules` path belongs to, and the package it - * holds — `{ installRoot, name }`, or null for a path that names neither. + * Where a repo-relative `node_modules` path came from — `{ installRoot, name, + * entryPath }`, or null for a path that names no package. * - * The install root is the prefix before the **first** `node_modules` segment - * (`.` for the repo root, `clients/web` for a client install), while the package - * name comes from after the **last** one. That asymmetry is deliberate: a - * *nested* `node_modules/a/node_modules/b` is npm resolving a transitive - * conflict **inside one install**, which is routine and not a cross-install - * skew — the same reason the lockfile comparison reads only top-level entries. - * Folding it onto its outermost install is what keeps the candidate set the - * handful of genuinely install-crossing packages instead of every transitive - * duplicate in the tree. + * - `installRoot` is the prefix before the **first** `node_modules` segment + * (`.` for the repo root, `clients/web` for a client install). A *nested* + * `node_modules/a/node_modules/b` folds onto its outermost install, because it + * is npm resolving a transitive conflict **inside one install** — routine, and + * not the cross-install skew this feeds. Folding it is what keeps the + * candidate set the handful of genuinely install-crossing packages instead of + * every transitive duplicate in the tree. + * - `name` comes from after the **last** `node_modules`, so the nested copy is + * still attributed to the package it actually is. + * - `entryPath` is the package directory relative to that install — exactly the + * key npm writes in that install's lockfile (`node_modules/zod`, + * `node_modules/a/node_modules/zod`). Folding tells you *which install* loaded + * it; this tells you *which copy*, so the version can be read from the entry + * the program actually resolved rather than from whatever sits at the + * install's top level (Copilot, #1965 r1 — a nested copy at a different + * version would otherwise be compared against a top-level entry that is + * absent, or worse, present and aligned, and the real pair would pass). */ export function classifyModulePath(rel) { const parts = rel.split("/"); @@ -324,13 +332,13 @@ export function classifyModulePath(rel) { // `node_modules/.bin/…`, `node_modules/.package-lock.json`: npm's own // bookkeeping, not a package. if (!head || head.startsWith(".")) return null; - const name = head.startsWith("@") - ? parts[last + 2] && `${head}/${parts[last + 2]}` - : head; + const scoped = head.startsWith("@"); + const name = scoped ? parts[last + 2] && `${head}/${parts[last + 2]}` : head; if (!name) return null; // a bare `node_modules/@scope` path names no package return { installRoot: first === 0 ? "." : parts.slice(0, first).join("/"), name, + entryPath: parts.slice(first, last + (scoped ? 3 : 2)).join("/"), }; } @@ -340,9 +348,15 @@ export function classifyModulePath(rel) { * type in front of the type checker. * * `programs` is an iterable of `{ label, files }`, one per leaf tsconfig project, - * `files` being repo-relative `node_modules` paths. Returns a Map keyed by - * package name, each value `{ installRoots, programs }` naming where it was seen - * — the diagnostic a failure needs. + * `files` being repo-relative `node_modules` paths. Returns + * `Map<name, Map<programLabel, Map<installRoot, Set<entryPath>>>>` — the + * co-occurrences themselves, not a flattened name list. + * + * Keeping the structure is what lets the caller compare only the copies that met + * (Copilot, #1965 r1). Flattening to names hands the comparison a package that + * is aligned wherever it co-occurs, and a third install holding a different + * version of it then fails the guard even though no program ever loads that + * copy — with a diagnostic naming an install that was never involved. * * The two-install test is applied **within one program**, not across the run: a * package reached from the root install in web's program and from the client @@ -362,20 +376,20 @@ export function classifyModulePath(rel) { export function crossInstallPackages(programs) { const found = new Map(); for (const { label, files } of programs) { - const roots = new Map(); // name -> Set(installRoot), within THIS program + // Within THIS program: name -> installRoot -> the entry paths loaded from it. + const seen = new Map(); for (const rel of files) { const hit = classifyModulePath(rel); if (!hit) continue; - if (!roots.has(hit.name)) roots.set(hit.name, new Set()); - roots.get(hit.name).add(hit.installRoot); + if (!seen.has(hit.name)) seen.set(hit.name, new Map()); + const byRoot = seen.get(hit.name); + if (!byRoot.has(hit.installRoot)) byRoot.set(hit.installRoot, new Set()); + byRoot.get(hit.installRoot).add(hit.entryPath); } - for (const [name, installRoots] of roots) { - if (installRoots.size < 2) continue; - if (!found.has(name)) - found.set(name, { installRoots: new Set(), programs: new Set() }); - const entry = found.get(name); - for (const root of installRoots) entry.installRoots.add(root); - entry.programs.add(label); + for (const [name, byRoot] of seen) { + if (byRoot.size < 2) continue; // one install — nothing to relate + if (!found.has(name)) found.set(name, new Map()); + found.get(name).set(label, byRoot); } } return found; diff --git a/scripts/lib/tsc-program.test.mjs b/scripts/lib/tsc-program.test.mjs index fa9bb9e1..c76825ff 100644 --- a/scripts/lib/tsc-program.test.mjs +++ b/scripts/lib/tsc-program.test.mjs @@ -155,28 +155,46 @@ test("classifyModulePath: install root is the OUTERMOST node_modules, package th assert.deepEqual(classifyModulePath("node_modules/zod/index.d.ts"), { installRoot: ".", name: "zod", + entryPath: "node_modules/zod", }); assert.deepEqual( classifyModulePath("clients/web/node_modules/zod/index.d.ts"), - { installRoot: "clients/web", name: "zod" }, + { + installRoot: "clients/web", + name: "zod", + entryPath: "node_modules/zod", + }, ); // A scoped package keeps both segments. assert.deepEqual( classifyModulePath("node_modules/@modelcontextprotocol/sdk/dist/x.d.ts"), - { installRoot: ".", name: "@modelcontextprotocol/sdk" }, + { + installRoot: ".", + name: "@modelcontextprotocol/sdk", + entryPath: "node_modules/@modelcontextprotocol/sdk", + }, ); // A NESTED copy folds onto its outermost install: npm resolving a transitive - // conflict inside one install is routine, not a cross-install skew, and the - // lockfile comparison ignores it for the same reason. + // conflict inside one install is routine, not a cross-install skew. Its + // `entryPath` still names the copy itself, so the caller can price the version + // the program actually loaded (Copilot, #1965 r1). assert.deepEqual( classifyModulePath("node_modules/a/node_modules/zod/index.d.ts"), - { installRoot: ".", name: "zod" }, + { + installRoot: ".", + name: "zod", + entryPath: "node_modules/a/node_modules/zod", + }, ); assert.deepEqual( classifyModulePath( "clients/cli/node_modules/a/node_modules/@scope/b/index.d.ts", ), - { installRoot: "clients/cli", name: "@scope/b" }, + { + installRoot: "clients/cli", + name: "@scope/b", + entryPath: "node_modules/a/node_modules/@scope/b", + }, ); }); @@ -210,14 +228,11 @@ test("crossInstallPackages: two installs in ONE program is the whole test (#1965 }, ]); assert.deepEqual([...found.keys()], ["zod"]); - assert.deepEqual([...found.get("zod").installRoots].sort(), [ - ".", - "clients/web", - ]); - assert.deepEqual( - [...found.get("zod").programs], - ["clients/web/tsconfig.test.json"], - ); + const byRoot = found.get("zod").get("clients/web/tsconfig.test.json"); + assert.deepEqual([...byRoot.keys()].sort(), [".", "clients/web"]); + // The entry paths ride along so the caller can price each copy from its own + // lockfile entry (Copilot, #1965 r1). + assert.deepEqual([...byRoot.get(".")], ["node_modules/zod"]); }); test("crossInstallPackages: two installs across SEPARATE programs is not a candidate", () => { @@ -252,8 +267,26 @@ test("crossInstallPackages: every program that saw both copies is recorded", () { label: "web/test", files: both }, { label: "cli/src", files: ["node_modules/zod/index.d.ts"] }, ]); - assert.deepEqual([...found.get("zod").programs].sort(), [ + assert.deepEqual([...found.get("zod").keys()].sort(), [ "web/app", "web/test", ]); }); + +test("crossInstallPackages: a nested copy is kept as its own entry path (Copilot, #1965 r1)", () => { + // Folded onto the root install for candidacy, but recorded at the path it was + // loaded from so its real version can be read. + const found = crossInstallPackages([ + { + label: "p", + files: [ + "node_modules/a/node_modules/zod/index.d.ts", + "clients/web/node_modules/zod/index.d.ts", + ], + }, + ]); + assert.deepEqual( + [...found.get("zod").get("p").get(".")], + ["node_modules/a/node_modules/zod"], + ); +}); diff --git a/scripts/verify-dep-lockstep.main.test.mjs b/scripts/verify-dep-lockstep.main.test.mjs index eb410167..32819af9 100644 --- a/scripts/verify-dep-lockstep.main.test.mjs +++ b/scripts/verify-dep-lockstep.main.test.mjs @@ -44,14 +44,18 @@ const typescriptDir = path.dirname( ), ); -/** A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. */ +/** + * A lockfileVersion 3 lockfile, including the `""` root entry npm always writes. + * A key already containing `node_modules/` is used verbatim, so a nested install + * path (`node_modules/outer/node_modules/inner`) can be expressed. + */ const lock = (deps) => ({ lockfileVersion: 3, packages: { "": { name: "fixture" }, ...Object.fromEntries( Object.entries(deps).map(([name, version]) => [ - `node_modules/${name}`, + name.includes("node_modules/") ? name : `node_modules/${name}`, { version }, ]), ), @@ -76,6 +80,8 @@ function makeFixture({ webScripts, rawWebLock, webTsconfig, + rootNestedInner, + cliDeps, } = {}) { // realpath matters: on macOS `tmpdir()` is `/var/...`, a symlink to // `/private/var/...`. The guard only runs `main()` when `import.meta.url` @@ -161,9 +167,54 @@ function makeFixture({ } stub("", root, "solo", "export type Solo = string;\n"); - write("package-lock.json", lock(root)); + // The root reaches `inner` only through a copy NESTED under `outer`, at a + // version its top-level entry does not carry. npm's own conflict resolution, + // and the case that must still be priced from the entry the program resolved. + const rootLockDeps = { ...root }; + if (rootNestedInner) { + stub( + "node_modules/outer/", + { inner: rootNestedInner }, + "inner", + "export type Inner = number;\n", + ); + rootLockDeps["node_modules/outer/node_modules/inner"] = rootNestedInner; + } + + write("package-lock.json", lock(rootLockDeps)); write("clients/web/package-lock.json", rawWebLock ?? lock(web)); + // A second client, to prove a third install's copy is not dragged into a + // comparison it never took part in. Its program spans only its own install. + if (cliDeps) { + write("clients/cli/package.json", { + name: "cli", + scripts: { typecheck: "tsc --noEmit -p tsconfig.json" }, + }); + write("clients/cli/tsconfig.json", { + compilerOptions: { + noEmit: true, + module: "esnext", + target: "esnext", + moduleResolution: "bundler", + types: [], + }, + include: ["src/**/*.ts"], + }); + write( + "clients/cli/src/main.ts", + 'import type { Outer } from "outer";\nexport type FromCli = Outer;\n', + ); + stub( + "clients/cli/", + cliDeps, + "outer", + 'import type { Inner } from "inner";\nexport type Outer = Inner;\n', + ); + stub("clients/cli/", cliDeps, "inner", "export type Inner = number;\n"); + write("clients/cli/package-lock.json", lock(cliDeps)); + } + // The guard resolves its repo root from its own location, so it has to live // inside the fixture; its `lib/` imports come along. mkdirSync(path.join(dir, "scripts", "lib"), { recursive: true }); @@ -236,10 +287,10 @@ test("main: exits 1 and names the skewed package and every holder", () => { const { status, out } = runGuard(dir); assert.equal(status, 1, out); assert.match(out, /\bouter\b/); - assert.match(out, /1\.0\.0\s+\(\.\)/); - assert.match(out, /1\.2\.3\s+\(clients\/web\)/); + assert.match(out, /1\.0\.0\s+\(\.\/node_modules\/outer\)/); + assert.match(out, /1\.2\.3\s+\(clients\/web\/node_modules\/outer\)/); // The program that saw both copies is named, so the claim is checkable. - assert.match(out, /seen in clients\/web\/tsconfig\.json/); + assert.match(out, /in clients\/web\/tsconfig\.json/); // `inner` agrees, so it must not be reported. assert.doesNotMatch(out, /^\s+inner$/m); }, @@ -263,8 +314,8 @@ test("main: a package reached only through another package's .d.ts is caught (#1 const { status, out } = runGuard(dir); assert.equal(status, 1, out); assert.match(out, /^\s+inner$/m); - assert.match(out, /4\.0\.0\s+\(\.\)/); - assert.match(out, /4\.5\.6\s+\(clients\/web\)/); + assert.match(out, /4\.0\.0\s+\(\.\/node_modules\/inner\)/); + assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/); }, ); }); @@ -284,6 +335,45 @@ test("main: a package that reaches the program from ONE install is not a candida ); }); +test("main: a third install's copy is not dragged into a comparison it never joined (Copilot, #1965 r1)", () => { + // `clients/cli` holds `outer` at a different version, but its own program is + // the only one that loads that copy and no second install meets it there. + // Flattening the candidates to names would compare cli against web's aligned + // pair and fail — naming an install that never took part. + withFixture( + { cliDeps: { ...ALIGNED, outer: "9.9.9", inner: "9.9.9" } }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 0, out); + assert.doesNotMatch(out, /clients\/cli/); + }, + ); +}); + +test("main: a nested copy is priced from its own lockfile entry (Copilot, #1965 r1)", () => { + // The root reaches `inner` only through `node_modules/outer/node_modules/inner` + // at 9.9.9, while its top-level entry still reads 4.5.6 — the version web + // holds. Pricing the copy from the top-level entry would report the pair as + // agreeing and let a real 9.9.9-vs-4.5.6 program through. + withFixture( + { + rootDeps: { ...ALIGNED, outer: "1.0.0" }, + webDeps: ALIGNED, + rootNestedInner: "9.9.9", + }, + (dir) => { + const { status, out } = runGuard(dir); + assert.equal(status, 1, out); + assert.match(out, /^\s+inner$/m); + assert.match( + out, + /9\.9\.9\s+\(\.\/node_modules\/outer\/node_modules\/inner\)/, + ); + assert.match(out, /4\.5\.6\s+\(clients\/web\/node_modules\/inner\)/); + }, + ); +}); + test("main: exits 1 when a client names no tsconfig project", () => { // No `typecheck` script and no `tsconfig.json` references: none of that // client's programs is measured, so a skew reaching only them would pass. diff --git a/scripts/verify-dep-lockstep.mjs b/scripts/verify-dep-lockstep.mjs index 3fe3a40e..05d9277a 100644 --- a/scripts/verify-dep-lockstep.mjs +++ b/scripts/verify-dep-lockstep.mjs @@ -98,18 +98,23 @@ const repoRoot = path.resolve( const TOLERATED_SKEW = new Map(); /** - * Top-level installed versions of every package in a parsed lockfile, keyed by - * package name. Only `node_modules/<pkg>` entries count — a *nested* - * `node_modules/a/node_modules/b` is npm resolving a transitive conflict inside - * one install, which is routine and not what this guard is about (the same - * asymmetry `classifyModulePath` applies to a resolved path). + * Installed versions in a parsed lockfile, keyed by the **install path** npm + * writes — `node_modules/zod`, `node_modules/a/node_modules/zod`. + * + * Keyed by path rather than by package name so a version is read from the exact + * copy a program resolved (Copilot, #1965 r1). Reading only top-level entries + * would mean a nested copy that entered the program got compared against + * whatever sits at the install's top level — a different version, or nothing at + * all — and a real pair could pass. Nested copies are still not a *candidate* on + * their own (`classifyModulePath` folds them onto their outermost install); this + * is about pricing a copy correctly once a program has loaded it. */ -export function topLevelLockVersions(lock) { +export function lockVersionsByPath(lock) { const versions = new Map(); for (const [entryPath, entry] of Object.entries(lock?.packages ?? {})) { - const m = /^node_modules\/(@[^/]+\/[^/]+|[^@/][^/]*)$/.exec(entryPath); - if (!m || typeof entry?.version !== "string") continue; - versions.set(m[1], entry.version); + if (!entryPath.startsWith("node_modules/")) continue; + if (typeof entry?.version !== "string") continue; + versions.set(entryPath, entry.version); } return versions; } @@ -120,7 +125,7 @@ export function topLevelLockVersions(lock) { * the root project. * * This is checked rather than tolerated because the gate is deny-by-default and - * `topLevelLockVersions` returns an empty map for anything else. An unreadable + * `lockVersionsByPath` returns an empty map for anything else. An unreadable * lockfile would otherwise contribute no holders, and a real skew among the * remaining installs would be reported as aligned — the gate failing *open*, * which is the one way it must never fail (Copilot, #1962). A v1 lockfile @@ -141,22 +146,48 @@ export function hasReadableLockShape(lock) { } /** - * Find candidate packages that resolve to more than one version across the - * installs. `installs` is an array of `{ dir, versions }`. Returns one entry per - * skewed package, sorted by name, each listing the version each install holds. - * Packages present in fewer than two installs cannot skew and are skipped. + * Price each co-occurrence and keep the ones whose copies disagree. + * + * `found` is {@link crossInstallPackages}' output — + * `Map<name, Map<program, Map<installRoot, Set<entryPath>>>>` — and `versions` + * maps an install dir to that install's {@link lockVersionsByPath}. A package is + * skewed when the copies **one program** loaded, from two different installs, + * carry more than one version. Only the installs that actually met in that + * program are compared: a third install's copy that no program loads is not + * evidence of anything (Copilot, #1965 r1). + * + * Returns `{ skewed, unresolved }`, sorted by name. `unresolved` names a copy + * whose lockfile entry is missing — the tree and the lockfile disagree, so the + * comparison cannot be trusted and the caller must fail rather than skip it. */ -export function findSkew(candidates, installs) { +export function findSkew(found, versions) { const skewed = []; - for (const name of [...candidates].sort()) { - const holders = installs - .filter(({ versions }) => versions.has(name)) - .map(({ dir, versions }) => ({ dir, version: versions.get(name) })); - if (holders.length < 2) continue; - const distinct = new Set(holders.map((h) => h.version)); - if (distinct.size > 1) skewed.push({ name, holders }); + const unresolved = []; + for (const name of [...found.keys()].sort()) { + const occurrences = []; + for (const [program, byRoot] of found.get(name)) { + const holders = []; + for (const [dir, entryPaths] of byRoot) + for (const entryPath of entryPaths) { + const version = versions.get(dir)?.get(entryPath); + if (version === undefined) unresolved.push({ name, dir, entryPath }); + else holders.push({ dir, entryPath, version }); + } + if (new Set(holders.map((h) => h.version)).size > 1) + occurrences.push({ program, holders }); + } + if (occurrences.length > 0) skewed.push({ name, occurrences }); } - return skewed; + return { skewed, unresolved }; +} + +/** Every holder across a skewed package's occurrences, deduped by install + path. */ +export function skewHolders(entry) { + const byKey = new Map(); + for (const { holders } of entry.occurrences) + for (const holder of holders) + byKey.set(`${holder.dir}|${holder.entryPath}`, holder); + return [...byKey.values()]; } /** @@ -181,7 +212,7 @@ export function majorOf(version) { export function partitionSkew(skewed, tolerated = TOLERATED_SKEW) { const isTolerated = (s) => { if (!tolerated.has(s.name)) return false; - const majors = new Set(s.holders.map((h) => majorOf(h.version))); + const majors = new Set(skewHolders(s).map((h) => majorOf(h.version))); return majors.size === 1 && !majors.has(null); }; return { @@ -343,8 +374,6 @@ export function main() { ); process.exit(1); } - const candidates = new Set(found.keys()); - const dirs = installDirs(); // A missing lockfile is a failure, not a skipped install: dropping one would @@ -398,32 +427,53 @@ export function main() { process.exit(1); } - const installs = locks.map(({ dir, lock }) => ({ - dir, - versions: topLevelLockVersions(lock), - })); + const versions = new Map( + locks.map(({ dir, lock }) => [dir, lockVersionsByPath(lock)]), + ); - const { failures, ignored } = partitionSkew(findSkew(candidates, installs)); + const { skewed, unresolved } = findSkew(found, versions); + + // A copy the program loaded but the lockfile doesn't list means the installed + // tree and the lockfile disagree — the comparison would be reading a version + // that isn't the one on disk, so refuse it rather than skip the copy. + if (unresolved.length > 0) { + console.error( + `verify:dep-lockstep — ${unresolved.length} resolved package(s) have no lockfile entry:\n`, + ); + for (const { name, dir, entryPath } of unresolved) + console.error(` ${name} ${dir}/${entryPath}`); + console.error( + "\nThe program loaded these, so their versions decide the comparison — but the install's lockfile" + + "\ndoesn't list them, which means the tree and the lockfile disagree. Run `npm install` at the repo" + + "\nroot to re-sync every install.", + ); + process.exit(1); + } + + const { failures, ignored } = partitionSkew(skewed); if (failures.length > 0) { console.error( `verify:dep-lockstep — ${failures.length} ${failures.length === 1 ? "dependency resolves" : "dependencies resolve"} to different versions across installs:\n`, ); let anyListed = false; - for (const { name, holders } of failures) { + for (const failure of failures) { // A package already on the allowlist reached here only by skewing across // a MAJOR boundary, so say that rather than advising an entry that exists. - const listed = TOLERATED_SKEW.has(name); + const listed = TOLERATED_SKEW.has(failure.name); anyListed ||= listed; console.error( - ` ${name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, + ` ${failure.name}${listed ? " (allowlisted — but this is a MAJOR skew)" : ""}`, ); - for (const { dir, version } of holders) - console.error(` ${version} (${dir})`); - // Which program saw both copies is the whole reason the package is a - // candidate — without it the reader has no way to check the claim. - for (const label of [...found.get(name).programs].sort()) - console.error(` seen in ${label}`); + // Report per program: which copies met, and where. The program is the + // whole reason the package is a candidate, and naming only the versions + // would leave the reader unable to check the claim — or to tell a nested + // copy from the install's top-level one. + for (const { program, holders } of failure.occurrences) { + console.error(` in ${program}`); + for (const { dir, entryPath, version } of holders) + console.error(` ${version} (${dir}/${entryPath})`); + } } console.error( "\nEach of these reaches a single `tsc` program from two installs (a client's own sources resolve" + @@ -447,7 +497,7 @@ export function main() { const note = ignored.length > 0 ? `, ${ignored.length} tolerated` : ""; console.log( - `verify:dep-lockstep — OK: ${candidates.size} install-crossing dependencies agree across ${installs.length} installs${note} ` + + `verify:dep-lockstep — OK: ${found.size} install-crossing dependencies agree across ${dirs.length} installs${note} ` + `(derived from ${programs.length} tsc programs).`, ); } diff --git a/scripts/verify-dep-lockstep.test.mjs b/scripts/verify-dep-lockstep.test.mjs index 25a643ab..edcbbc4f 100644 --- a/scripts/verify-dep-lockstep.test.mjs +++ b/scripts/verify-dep-lockstep.test.mjs @@ -15,9 +15,9 @@ import { clientProjects, findSkew, hasReadableLockShape, + lockVersionsByPath, majorOf, partitionSkew, - topLevelLockVersions, } from "./verify-dep-lockstep.mjs"; test("clientProjects: a `typecheck` script's projects win over references", () => { @@ -60,11 +60,11 @@ test("clientProjects: a NEUTERED project still contributes its program (#1965)", ); }); -test("topLevelLockVersions: nested duplicates are ignored", () => { - // A nested `node_modules/a/node_modules/b` is npm resolving a transitive - // conflict *inside* one install — routine, and not the cross-install skew - // this guard is about (`cosmiconfig`'s yaml@1 alongside the top-level yaml@2 - // is the live example). +test("lockVersionsByPath: keyed by install path, nested entries included (Copilot, #1965 r1)", () => { + // Keyed by PATH, not by package name: a nested copy that entered a program has + // to be priced from its own entry. Reading only `node_modules/<pkg>` would + // compare it against the install's top-level copy — a different version, or + // none — and a real pair could pass. const lock = { packages: { "": { name: "root" }, @@ -75,18 +75,173 @@ test("topLevelLockVersions: nested duplicates are ignored", () => { "node_modules/no-version": { resolved: "https://example.test/x.tgz" }, }, }; - assert.deepEqual([...topLevelLockVersions(lock)].sort(), [ - ["@modelcontextprotocol/client", "2.0.0-beta.5"], - ["yaml", "2.9.0"], - ["zod", "4.4.3"], + assert.deepEqual([...lockVersionsByPath(lock)].sort(), [ + ["node_modules/@modelcontextprotocol/client", "2.0.0-beta.5"], + ["node_modules/cosmiconfig/node_modules/yaml", "1.10.3"], + ["node_modules/yaml", "2.9.0"], + ["node_modules/zod", "4.4.3"], ]); }); -test("topLevelLockVersions: a malformed or empty lockfile yields nothing", () => { +test("lockVersionsByPath: a malformed or empty lockfile yields nothing", () => { // Safe as a pure helper *because* `hasReadableLockShape` rejects these before // any comparison — an empty map reaching `findSkew` is the fail-open path. for (const lock of [undefined, null, {}, { packages: {} }]) - assert.equal(topLevelLockVersions(lock).size, 0); + assert.equal(lockVersionsByPath(lock).size, 0); +}); + +/** `crossInstallPackages`-shaped input: name → program → install → entry paths. */ +const occurrence = (name, program, byRoot) => + new Map([ + [ + name, + new Map([ + [ + program, + new Map( + Object.entries(byRoot).map(([dir, paths]) => [dir, new Set(paths)]), + ), + ], + ]), + ], + ]); + +const lockPaths = (byDir) => + new Map( + Object.entries(byDir).map(([dir, entries]) => [ + dir, + new Map(Object.entries(entries)), + ]), + ); + +test("findSkew: reports the copies one program loaded, with their paths", () => { + const { skewed, unresolved } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.3.6" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual(unresolved, []); + assert.deepEqual(skewed, [ + { + name: "zod", + occurrences: [ + { + program: "clients/web/tsconfig.test.json", + holders: [ + { dir: ".", entryPath: "node_modules/zod", version: "4.3.6" }, + { + dir: "clients/web", + entryPath: "node_modules/zod", + version: "4.4.3", + }, + ], + }, + ], + }, + ]); +}); + +test("findSkew: only the installs that MET in a program are compared (Copilot, #1965 r1)", () => { + // `clients/cli` holds a different zod, but no program loads it beside another + // copy — nothing has to relate the two, so it is not a finding, and naming cli + // in a diagnostic about web's program would be wrong as well as noisy. + const { skewed } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.4.3" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + "clients/cli": { "node_modules/zod": "4.3.6" }, + }), + ); + assert.deepEqual(skewed, []); +}); + +test("findSkew: a nested copy is priced from its own entry (Copilot, #1965 r1)", () => { + // The root loaded zod through `a`'s nested copy. Pricing it from the root's + // TOP-LEVEL entry (4.4.3, aligned with web) would report the pair as agreeing. + const { skewed } = findSkew( + occurrence("zod", "clients/web/tsconfig.test.json", { + ".": ["node_modules/a/node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { + "node_modules/zod": "4.4.3", + "node_modules/a/node_modules/zod": "3.1.0", + }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual( + skewed[0].occurrences[0].holders.map((h) => `${h.dir}:${h.version}`), + [".:3.1.0", "clients/web:4.4.3"], + ); +}); + +test("findSkew: agreement is not skew", () => { + const { skewed, unresolved } = findSkew( + occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ + ".": { "node_modules/zod": "4.4.3" }, + "clients/web": { "node_modules/zod": "4.4.3" }, + }), + ); + assert.deepEqual(skewed, []); + assert.deepEqual(unresolved, []); +}); + +test("findSkew: a copy with no lockfile entry is reported, not skipped", () => { + // Skipping it would drop a holder from the comparison and could report a real + // skew as agreement — the gate failing open. + const { skewed, unresolved } = findSkew( + occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + lockPaths({ ".": { "node_modules/zod": "4.4.3" }, "clients/web": {} }), + ); + assert.deepEqual(unresolved, [ + { name: "zod", dir: "clients/web", entryPath: "node_modules/zod" }, + ]); + assert.deepEqual(skewed, []); +}); + +test("findSkew: results are sorted by package name", () => { + const found = new Map([ + ...occurrence("zod", "p", { + ".": ["node_modules/zod"], + "clients/web": ["node_modules/zod"], + }), + ...occurrence("hono", "p", { + ".": ["node_modules/hono"], + "clients/web": ["node_modules/hono"], + }), + ]); + const { skewed } = findSkew( + found, + lockPaths({ + ".": { "node_modules/zod": "1.0.0", "node_modules/hono": "1.0.0" }, + "clients/web": { + "node_modules/zod": "2.0.0", + "node_modules/hono": "2.0.0", + }, + }), + ); + assert.deepEqual( + skewed.map((s) => s.name), + ["hono", "zod"], + ); }); test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot, #1962)", () => { @@ -124,89 +279,32 @@ test("hasReadableLockShape: only a v2+ packages table with a root entry (Copilot assert.equal(hasReadableLockShape(lock), false, JSON.stringify(lock)); }); -test("findSkew: reports a package held at two versions", () => { - const installs = [ - { dir: ".", versions: new Map([["zod", "4.3.6"]]) }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - { dir: "clients/cli", versions: new Map([["zod", "4.4.3"]]) }, - ]; - assert.deepEqual(findSkew(new Set(["zod"]), installs), [ - { - name: "zod", - holders: [ - { dir: ".", version: "4.3.6" }, - { dir: "clients/web", version: "4.4.3" }, - { dir: "clients/cli", version: "4.4.3" }, - ], - }, - ]); -}); - -test("findSkew: agreement and single-install packages are not skew", () => { - const installs = [ - { - dir: ".", - versions: new Map([ - ["zod", "4.4.3"], - ["express", "5.2.1"], - ]), - }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - ]; - // `express` lives in one install only, so it cannot skew — a package absent - // from a client is not a finding. - assert.deepEqual(findSkew(new Set(["zod", "express"]), installs), []); -}); - -test("findSkew: a candidate in no lockfile is inert", () => { - // `@inspector/core` is a build-time alias, not a package; the scan picks it - // up and it must drop out here rather than error. - const installs = [ - { dir: ".", versions: new Map([["zod", "4.4.3"]]) }, - { dir: "clients/web", versions: new Map([["zod", "4.4.3"]]) }, - ]; - assert.deepEqual(findSkew(new Set(["@inspector/core"]), installs), []); -}); - -test("findSkew: results are sorted by package name", () => { - const installs = [ - { - dir: ".", - versions: new Map([ - ["zod", "1.0.0"], - ["hono", "1.0.0"], - ]), - }, - { - dir: "clients/web", - versions: new Map([ - ["zod", "2.0.0"], - ["hono", "2.0.0"], - ]), - }, - ]; - assert.deepEqual( - findSkew(new Set(["zod", "hono"]), installs).map((s) => s.name), - ["hono", "zod"], - ); -}); - test("partitionSkew: the allowlist is by name, not by version pair", () => { // So an ordinary patch float within a tolerated package does not churn the // allowlist, while any *unlisted* package that starts skewing still fails. const skewed = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, { name: "zod", - holders: [ - { dir: ".", version: "4.3.6" }, - { dir: "clients/web", version: "4.4.3" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "4.3.6" }, + { dir: "clients/web", version: "4.4.3" }, + ], + }, ], }, ]; @@ -223,7 +321,14 @@ test("partitionSkew: the allowlist is by name, not by version pair", () => { }); test("partitionSkew: deny by default — nothing tolerated fails everything", () => { - const skewed = [{ name: "zod", holders: [{ dir: ".", version: "1.0.0" }] }]; + const skewed = [ + { + name: "zod", + occurrences: [ + { program: "p", holders: [{ dir: ".", version: "1.0.0" }] }, + ], + }, + ]; assert.equal(partitionSkew(skewed, new Map()).failures.length, 1); }); @@ -235,18 +340,28 @@ test("partitionSkew: the allowlist tolerates skew only within a major (Copilot, const withinMajor = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, ]; const acrossMajor = [ { name: "react", - holders: [ - { dir: ".", version: "18.3.1" }, - { dir: "clients/web", version: "19.2.8" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "18.3.1" }, + { dir: "clients/web", version: "19.2.8" }, + ], + }, ], }, ]; @@ -261,9 +376,14 @@ test("partitionSkew: an unparseable version can't be proven same-major, so it fa const skewed = [ { name: "react", - holders: [ - { dir: ".", version: "19.2.7" }, - { dir: "clients/web", version: "next" }, + occurrences: [ + { + program: "p", + holders: [ + { dir: ".", version: "19.2.7" }, + { dir: "clients/web", version: "next" }, + ], + }, ], }, ]; From 73913ef09ff7b63992cb9863ebe41d858418eeca Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:11:29 -0400 Subject: [PATCH 170/941] fix(web): read modern tasks support from the extension map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEP-2663 moved task support from `capabilities.tasks` to the negotiated `capabilities.extensions["io.modelcontextprotocol/tasks"]`, but the Connection Info modal's capability grid tested only for the top-level key. A modern server that advertised the extension in `server/discover` therefore showed "Tasks ✗" while the very same extension id was listed under "Server Extensions" two sections below. Server capability rows now fall back to an extension id for the capabilities the modern era expresses that way (tasks is the only one today), gated on the negotiated era so a legacy connection is still judged purely on its `initialize` capabilities. Keyed the same way `InspectorClient.isTasksExtensionNegotiated()` gates the Tasks tab, so the checkmark and the tab can't disagree. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ConnectionInfoContent.stories.tsx | 8 ++ .../ConnectionInfoContent.test.tsx | 103 ++++++++++++++++++ .../ConnectionInfoContent.tsx | 52 ++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index c6f1e598..eaf54cee 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -77,6 +77,14 @@ export const ModernEra: Story = { }, }, }, + // SEP-2663 moved task support to the extension map, so the Tasks capability + // row must read it there on a modern connection — otherwise a tasks-capable + // server shows ✗ while listing the extension below (#1887). + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const tasksRow = canvas.getByText("Tasks"); + await expect(tasksRow.previousElementSibling).toHaveTextContent("✓"); + }, }; // A modern server that omitted the optional `_meta` serverInfo stamp (#1772). diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index f9f3d2fa..db49986b 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -31,6 +31,21 @@ const fullClientCaps: ClientCapabilities = { experimental: {}, }; +// The glyphs `CapabilityItem` renders beside each capability label. +const SUPPORTED_MARK = "✓"; +const UNSUPPORTED_MARK = "✗"; + +/** + * Read the ✓/✗ a capability row is showing. The mark is the label's preceding + * sibling inside the row `Group`, so asserting on it — rather than on the + * label's mere presence — is what distinguishes "supported" from "listed". + */ +function capabilityMark(label: string): string | undefined { + return ( + screen.getByText(label).previousElementSibling?.textContent ?? undefined + ); +} + describe("ConnectionInfoContent", () => { it("renders server implementation fields under the heading", () => { renderWithMantine( @@ -181,6 +196,94 @@ describe("ConnectionInfoContent", () => { expect(screen.getByText("Sampling")).toBeInTheDocument(); }); + it("marks Tasks supported on a modern connection that advertises the tasks extension (#1887)", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={{ + ...fullResult, + capabilities: { + ...fullResult.capabilities, + // SEP-2663: no top-level `tasks` key — support is the extension. + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }} + clientCapabilities={fullClientCaps} + transport="streamable-http" + protocolEra="modern" + />, + ); + expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK); + }); + + it("still marks Tasks unsupported on a modern connection without the extension (#1887)", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={fullResult} + clientCapabilities={fullClientCaps} + transport="streamable-http" + protocolEra="modern" + />, + ); + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + + it("does not read the tasks extension on a legacy connection (#1887)", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={{ + ...fullResult, + capabilities: { + ...fullResult.capabilities, + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + }} + clientCapabilities={fullClientCaps} + transport="streamable-http" + protocolEra="legacy" + />, + ); + // Legacy tasks support is `capabilities.tasks`, which this server omits — + // an extension key alone must not turn the row green. + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + + it("marks Tasks supported on a legacy connection from capabilities.tasks", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={{ + ...fullResult, + capabilities: { + ...fullResult.capabilities, + tasks: { list: true, cancel: true }, + }, + }} + clientCapabilities={fullClientCaps} + transport="streamable-http" + protocolEra="legacy" + />, + ); + expect(capabilityMark("Tasks")).toBe(SUPPORTED_MARK); + }); + + it("does not extension-promote a capability other than tasks (#1887)", () => { + renderWithMantine( + <ConnectionInfoContent + initializeResult={{ + ...fullResult, + capabilities: { + tools: { listChanged: true }, + extensions: { "io.modelcontextprotocol/ui": {} }, + }, + }} + clientCapabilities={fullClientCaps} + transport="streamable-http" + protocolEra="modern" + />, + ); + expect(capabilityMark("Prompts")).toBe(UNSUPPORTED_MARK); + expect(capabilityMark("Tasks")).toBe(UNSUPPORTED_MARK); + }); + it("renders instructions when present", () => { renderWithMantine( <ConnectionInfoContent diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index dde264b6..9ee75b8a 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -14,8 +14,10 @@ import type { DiscoverResult, InitializeResult, ProtocolEra, + ServerCapabilities, } from "@modelcontextprotocol/client"; import type { ServerType } from "@inspector/core/mcp/types.js"; +import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js"; import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js"; import { CapabilityItem, @@ -189,16 +191,62 @@ const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [ export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL = "Clear OAuth state and disconnect"; +/** + * Server capabilities the modern (2026-07-28) era expresses as a negotiated + * *extension* rather than a top-level `capabilities` key. `tasks` is the only + * one today: SEP-2663 moved task support to + * `capabilities.extensions["io.modelcontextprotocol/tasks"]`, so a modern + * tasks-capable server left the Tasks row showing a red ✗ while the very same + * extension id was listed under "Server Extensions" two sections below + * (#1887). Keyed the same way `InspectorClient.isTasksExtensionNegotiated()` + * gates the Tasks tab, so the checkmark and the tab agree. + */ +const MODERN_EXTENSION_BACKED_CAPABILITIES: Partial< + Record<CapabilityKey, string> +> = { + tasks: TASKS_EXTENSION_KEY, +}; + +function isCapabilityPresent( + capabilities: Record<string, unknown>, + key: CapabilityKey, +): boolean { + return key in capabilities && capabilities[key] != null; +} + function getCapabilityEntries( capabilities: Record<string, unknown>, knownKeys: CapabilityKey[], ): { capability: CapabilityKey; supported: boolean }[] { return knownKeys.map((key) => ({ capability: key, - supported: key in capabilities && capabilities[key] != null, + supported: isCapabilityPresent(capabilities, key), })); } +/** + * Server-side capability entries. Same presence rule as the client column, + * plus the modern extension fallback above — gated on the negotiated era so a + * legacy connection is still judged purely on its `initialize` capabilities. + */ +function getServerCapabilityEntries( + capabilities: ServerCapabilities, + era: ProtocolEra | undefined, +): { capability: CapabilityKey; supported: boolean }[] { + const modern = isModernEra(era); + return SERVER_CAPABILITY_KEYS.map((key) => { + const extensionKey = MODERN_EXTENSION_BACKED_CAPABILITIES[key]; + const viaExtension = + modern && + extensionKey !== undefined && + capabilities.extensions?.[extensionKey] != null; + return { + capability: key, + supported: isCapabilityPresent(capabilities, key) || viaExtension, + }; + }); +} + export function ConnectionInfoContent({ initializeResult, serverInfoReported = true, @@ -229,7 +277,7 @@ export function ConnectionInfoContent({ ? serverInfo.version?.trim() || "—" : SERVER_INFO_NOT_REPORTED_LABEL; - const serverCaps = getCapabilityEntries(capabilities, SERVER_CAPABILITY_KEYS); + const serverCaps = getServerCapabilityEntries(capabilities, protocolEra); const clientCaps = getCapabilityEntries( clientCapabilities, CLIENT_CAPABILITY_KEYS, From 0b54cdc4e1794743a5d60ec3d5a560f1cfbd12a0 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:25:49 -0400 Subject: [PATCH 171/941] test(web): use the object shape for the legacy tasks capability `ServerTasksCapability`'s `list`/`cancel` are objects, not booleans, so the legacy-tasks fixture failed `tsc -b` in CI. A stale incremental build cache hid it locally. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ConnectionInfoContent/ConnectionInfoContent.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index db49986b..0ebb740f 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -254,7 +254,9 @@ describe("ConnectionInfoContent", () => { ...fullResult, capabilities: { ...fullResult.capabilities, - tasks: { list: true, cancel: true }, + // `ServerTasksCapability`'s sub-capabilities are objects, not + // booleans — `{}` is the "supported, no sub-options" shape. + tasks: { list: {}, cancel: {} }, }, }} clientCapabilities={fullClientCaps} From b3aedf62cdd92fb6e48140612c97ff6a0a3e57fb Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:31:01 -0400 Subject: [PATCH 172/941] fix(web): name an empty tool result instead of "No results yet" (#1860) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tools screen mounts the Results panel only once a call has produced a result, but the panel's zero-content branch rendered "No results yet" — the same wording it would use before anything had run. An empty `content` array with no `structuredContent` is a legal `CallToolResult`, so a call that had plainly completed reported itself as a call that never happened. That reads worst after a multi-round-trip (MRTR) sequence, which is how it was reported: the user answers one or more embedded elicitations, watches the Protocol tab group the exchange and mark it COMPLETE, and is then told there are no results yet. Nothing was lost — the result was stored and rendered; the panel simply had no wording for "completed, returned nothing". The panel now renders an "Empty result" alert saying the call completed and returned no content. This is the remaining half of the same gap #1908 closed for `structuredContent`-only results. test-servers: new `mrtr_empty` preset (`createMrtrEmptyResultTool`) — one elicitation round, then a complete result with `content: []` and no `structuredContent` — added to `mrtr-showcase-http.json` so the shape is reproducible by hand. README documents it. Signed-off-by: Cliff Hall <cliff@futurescale.com> Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 15 ++++++- .../ToolResultPanel/ToolResultPanel.test.tsx | 15 +++++-- .../ToolResultPanel/ToolResultPanel.tsx | 15 ++++++- test-servers/configs/mrtr-showcase-http.json | 1 + test-servers/src/preset-registry.ts | 3 ++ test-servers/src/test-server-fixtures.ts | 44 +++++++++++++++++++ 6 files changed, 87 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cb57dd35..d10c191f 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | -| `mrtr-showcase-http.json` | Every MRTR preset in one server | — | +| `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | | `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | | `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | @@ -175,8 +175,21 @@ The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so | `mrtr_sample` | Embedded sampling → the Sampling panel | | `mrtr_roots` | Embedded `roots/list`, auto-answered silently from configured roots (no modal) | | `mrtr_edge` | An `inputRequests`-only round, then a `requestState`-only round | +| `mrtr_empty` | Completes with an empty result — no `content`, no `structuredContent` | | `mrtr_loop` | Never completes → trips the `MRTR_MAX_ROUNDS` bound | +Run `mrtr_empty` and answer its single elicitation: the Protocol tab groups the +exchange as an MRTR conversation ending **COMPLETE**, and the Results panel says +**"Empty result — The tool call completed successfully and returned no content."** +On the broken build that same result rendered as **"No results yet"**, the panel's +pre-run placeholder ([#1860](https://github.com/modelcontextprotocol/inspector/issues/1860)) — +so a call the user had just watched succeed read as a call that never ran. An +empty `content` array with no `structuredContent` is a legal `CallToolResult`, +and the panel only ever mounts once a result exists, so the placeholder wording +could not be true there. (The neighbouring half of the same gap — a result whose +payload lives only in `structuredContent` — was closed by +[#1908](https://github.com/modelcontextprotocol/inspector/issues/1908).) + > The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there. MRTR is the modern replacement. #### Network tab — standardized headers and error taxonomy diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx index 1be9f2c3..0820e01b 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.test.tsx @@ -35,11 +35,20 @@ describe("ToolResultPanel", () => { expect(screen.getByText("boom")).toBeInTheDocument(); }); - it("renders the empty state when content is empty", () => { + // #1860: this panel only mounts once a result exists, so an empty result must + // read as "the call completed and returned nothing" — never as the pre-run + // "No results yet" placeholder, which denies a call the user watched succeed. + it("names the outcome when a completed result has no content", () => { renderWithMantine( <ToolResultPanel result={emptyResult} onClear={() => {}} />, ); - expect(screen.getByText("No results yet")).toBeInTheDocument(); + expect(screen.getByText("Empty result")).toBeInTheDocument(); + expect( + screen.getByText( + "The tool call completed successfully and returned no content.", + ), + ).toBeInTheDocument(); + expect(screen.queryByText("No results yet")).not.toBeInTheDocument(); }); it("groups resource_link blocks in a scrollable Resource Links box", async () => { @@ -141,7 +150,7 @@ describe("ToolResultPanel", () => { onClear={() => {}} />, ); - expect(screen.queryByText("No results yet")).not.toBeInTheDocument(); + expect(screen.queryByText("Empty result")).not.toBeInTheDocument(); expect( screen.getByRole("heading", { name: "Structured Output" }), ).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx index 33ca7761..772965dc 100644 --- a/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx +++ b/clients/web/src/components/groups/ToolResultPanel/ToolResultPanel.tsx @@ -5,7 +5,6 @@ import { Paper, ScrollArea, Stack, - Text, Title, } from "@mantine/core"; import type { @@ -176,6 +175,16 @@ const ErrorAlert = Alert.withProps({ title: "Tool Error", }); +// A completed call whose result renders nothing. `content: []` with no +// `structuredContent` is a legal CallToolResult, and this panel is only mounted +// once a result exists — so saying "no results yet" here would deny a call the +// user just watched succeed (#1860). Name the outcome instead. +const EmptyResultAlert = Alert.withProps({ + color: "gray", + variant: "light", + title: "Empty result", +}); + function ResourceLinksGroup({ links, onReadResource, @@ -277,7 +286,9 @@ export function ToolResultPanel({ </ResultScroll> ) : result.content.length === 0 && !structuredNode ? ( <ResultScroll> - <Text c="dimmed">No results yet</Text> + <EmptyResultAlert> + The tool call completed successfully and returned no content. + </EmptyResultAlert> </ResultScroll> ) : hasLinks ? ( <FillStack> diff --git a/test-servers/configs/mrtr-showcase-http.json b/test-servers/configs/mrtr-showcase-http.json index 026d43c0..84e309fd 100644 --- a/test-servers/configs/mrtr-showcase-http.json +++ b/test-servers/configs/mrtr-showcase-http.json @@ -10,6 +10,7 @@ { "preset": "mrtr_sample" }, { "preset": "mrtr_roots" }, { "preset": "mrtr_edge" }, + { "preset": "mrtr_empty" }, { "preset": "mrtr_loop" } ], "transport": { diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index d78998ec..be840183 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -29,6 +29,7 @@ import { createMrtrSamplingTool, createMrtrLoopTool, createMrtrEdgeCaseTool, + createMrtrEmptyResultTool, createCollectUrlElicitationTool, createUrlElicitationFormTool, createSendNotificationTool, @@ -151,6 +152,8 @@ function resolveToolPreset( return createMrtrLoopTool(); case "mrtr_edge": return createMrtrEdgeCaseTool(); + case "mrtr_empty": + return createMrtrEmptyResultTool(); case "collect_url_elicitation": return createCollectUrlElicitationTool(); case "url_elicitation_form": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 070b5750..469100ea 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -755,6 +755,50 @@ export function createMrtrEdgeCaseTool(): ToolDefinition { }; } +/** + * An MRTR tool whose FINAL (complete) result carries an empty `content` array + * and no `structuredContent` — a legal `CallToolResult` that renders nothing. + * + * This is the shape behind #1860: the sequence completes (the Protocol tab shows + * the conversation COMPLETE), a real result is stored, and the Results panel had + * no way to say so — it fell through to the same "No results yet" placeholder it + * shows before anything has run, so a successful call read as a call that never + * happened. Kept as an MRTR tool rather than a plain one because the multi-round + * case is where the ambiguity actually misleads: the user has answered prompts + * and watched rounds complete, so "no results yet" is flatly contradicted by + * what they just did. + */ +export function createMrtrEmptyResultTool(): ToolDefinition { + return { + name: "mrtr_empty", + description: + "MRTR tool that completes with an empty result (no content, no structuredContent).", + inputSchema: {}, + handler: async ( + _params: Record<string, unknown>, + _context?: TestServerContext, + extra?: HandlerExtra, + ) => { + if (extra?.inputResponses?.ack === undefined) { + return inputRequired({ + inputRequests: { + ack: inputRequired.elicit({ + message: "Acknowledge to finish (the result will be empty)", + requestedSchema: { + type: "object", + properties: { ack: { type: "boolean", title: "Acknowledge" } }, + required: ["ack"], + }, + }), + }, + requestState: `mrtr-empty:${++mrtrMintCount}`, + }); + } + return { content: [] } satisfies CallToolResult; + }, + }; +} + /** * Create a "url_elicitation_form" tool that spins up a simple HTTP server on a dynamic * port with a form page, sends that URL via URL elicitation, and on form submit collects From 83658db2dd1c81ec49192a68378072c93d4d84f5 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:31:35 -0400 Subject: [PATCH 173/941] fix(web): expand resource templates per RFC 6570 The Resources screen discovered and substituted template variables with a bare `/\{(\w+)\}/g` regex, which is wrong two ways: it cannot see an expression carrying an operator, so `foobar://events{?topic}` rendered no input at all; and it splices the raw value in, so a `/`, `?`, `#`, `%`, space or non-ASCII character in a simple `{topic}` landed unencoded and changed the URI's structure -- `foo/bar` produced an extra path segment that a conforming matcher rejects with `-32602 Resource not found`. Delegate expansion to the SDK's `UriTemplate`, the same implementation `InspectorClient.readResourceFromTemplate` (and so the TUI) already expands through, so the two clients cannot disagree about what a template means. The new `utils/uriTemplate` supplies only what that class does not: which variables to render an input for, which of them a read cannot proceed without, and a partially-expanded preview. Required-ness follows the operator. Under `?`, `&`, `.` or `/` the whole expression is omitted when the variable is undefined, so those fields are marked Optional and reading with them blank is a legitimate request for the unfiltered resource; under `""`, `+` or `#` the variable sits mid-URI, so it stays required. Blank fields are dropped before expanding so an untouched optional field reads as undefined rather than as the empty string, which would expand to a valueless `?topic=`. Adds the `rfc6570_templates` preset and a `rfc6570-templates-http.json` showcase server serving the two templates from the issue. Closes #1919 Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 11 + .../ResourceTemplatePanel.test.tsx | 91 ++++++++ .../ResourceTemplatePanel.tsx | 61 +++-- clients/web/src/utils/uriTemplate.test.ts | 168 ++++++++++++++ clients/web/src/utils/uriTemplate.ts | 219 ++++++++++++++++++ .../configs/rfc6570-templates-http.json | 12 + test-servers/src/preset-registry.ts | 3 + test-servers/src/test-server-fixtures.ts | 47 ++++ 8 files changed, 578 insertions(+), 34 deletions(-) create mode 100644 clients/web/src/utils/uriTemplate.test.ts create mode 100644 clients/web/src/utils/uriTemplate.ts create mode 100644 test-servers/configs/rfc6570-templates-http.json diff --git a/README.md b/README.md index cb57dd35..de94130e 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | | `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | | `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | +| `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | | `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | | `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | | `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | @@ -237,6 +238,16 @@ Open the Tools tab and select `record_shipment`: `direction` must render as a ** The **TUI** had the same gap and is worth checking against the same server (`--tui`, then test `record_shipment`): `direction` is a select, `quantity` an integer field, `express` a boolean. Both clients now share one collapse step — `normalizeNullableUnion` in [`core/json/nullableUnion.ts`](./core/json/nullableUnion.ts) — precisely so they cannot drift on which schemas they can render. +#### RFC 6570 resource templates + +`rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against and the variables the server decoded. Plain streamable-HTTP; connect with the **default (legacy)** protocol era. + +Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The request must go out as `foobar://events/foo%2Fbar`, and the result echoes back the URI the server matched. On the broken build the value was spliced in raw, so the slash created a second path segment and the SDK's matcher answered `-32602 Resource not found: foobar://events/foo/bar` — the exact failure in the issue. The same holds for `?`, `#`, `%`, spaces, and non-ASCII text. + +**events_by_query** is the half that was invisible: the old `/\{(\w+)\}/g` scan could not see an expression carrying an operator, so no `topic` input was rendered at all. It now appears, marked **Optional** — RFC 6570 drops the whole expression when the variable is undefined, so reading with the field blank requests `foobar://events`, and filling it in requests `foobar://events?topic=foo%2Fbar`. The URI preview beside the title shows the partially-expanded form as you type, leaving unfilled expressions standing as written. + +Both clients expand through the SDK's `UriTemplate` — the web client via [`clients/web/src/utils/uriTemplate.ts`](./clients/web/src/utils/uriTemplate.ts), the TUI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. + #### Advertised extensions `advertised-extensions-http.json` serves `echo` (always) and a `get_weather` tool **gated on the `io.modelcontextprotocol/tasks` extension** (`extensionGatedTools`): the tool is registered but starts disabled, and the server enables it on `notifications/initialized` only when the client declared that extension in its `capabilities.extensions`. diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index 596a9340..4955b385 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -27,6 +27,11 @@ const noVarTemplate: ResourceTemplate = { uriTemplate: "file:///static.txt", }; +const queryTemplate: ResourceTemplate = { + name: "Events", + uriTemplate: "foobar://events{?topic}", +}; + describe("ResourceTemplatePanel", () => { it("renders the template title (or name) and description", () => { renderWithMantine( @@ -154,6 +159,92 @@ describe("ResourceTemplatePanel", () => { ).not.toBeDisabled(); }); + describe("RFC 6570 expansion (#1919)", () => { + it("renders an input for a query expression the old regex could not see", () => { + renderWithMantine( + <ResourceTemplatePanel + template={queryTemplate} + onReadResource={vi.fn()} + />, + ); + expect(screen.getByLabelText("topic")).toBeInTheDocument(); + }); + + it("percent-encodes a reserved character in a simple variable", async () => { + const user = userEvent.setup(); + const onReadResource = vi.fn(); + renderWithMantine( + <ResourceTemplatePanel + template={singleVarTemplate} + onReadResource={onReadResource} + />, + ); + await user.type(screen.getByLabelText("userId"), "foo/bar"); + await user.click(screen.getByRole("button", { name: "Read Resource" })); + expect(onReadResource).toHaveBeenCalledWith( + "file:///users/foo%2Fbar/profile", + ); + }); + + it("builds an encoded query expression for {?topic}", async () => { + const user = userEvent.setup(); + const onReadResource = vi.fn(); + renderWithMantine( + <ResourceTemplatePanel + template={queryTemplate} + onReadResource={onReadResource} + />, + ); + await user.type(screen.getByLabelText("topic"), "foo/bar"); + await user.click(screen.getByRole("button", { name: "Read Resource" })); + expect(onReadResource).toHaveBeenCalledWith( + "foobar://events?topic=foo%2Fbar", + ); + }); + + it("marks a query variable Optional and does not gate the read on it", async () => { + const user = userEvent.setup(); + const onReadResource = vi.fn(); + renderWithMantine( + <ResourceTemplatePanel + template={queryTemplate} + onReadResource={onReadResource} + />, + ); + expect(screen.getByText("Optional")).toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Read Resource" }); + expect(button).not.toBeDisabled(); + // Left blank, the whole expression drops out per RFC 6570. + await user.click(button); + expect(onReadResource).toHaveBeenCalledWith("foobar://events"); + }); + + it("does not mark a required simple variable Optional", () => { + renderWithMantine( + <ResourceTemplatePanel + template={singleVarTemplate} + onReadResource={vi.fn()} + />, + ); + expect(screen.queryByText("Optional")).not.toBeInTheDocument(); + }); + + it("previews the query expression verbatim until it is filled", async () => { + const user = userEvent.setup(); + renderWithMantine( + <ResourceTemplatePanel + template={queryTemplate} + onReadResource={vi.fn()} + />, + ); + expect(screen.getByText("foobar://events{?topic}")).toBeInTheDocument(); + await user.type(screen.getByLabelText("topic"), "news"); + expect( + screen.getByText("foobar://events?topic=news"), + ).toBeInTheDocument(); + }); + }); + describe("completions", () => { it("fires a completion immediately on focus before any keystroke", async () => { const user = userEvent.setup(); diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index 6b506228..eafd16f4 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -14,6 +14,11 @@ import { useValueChange } from "../../../hooks/useValueChange"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; +import { + expandUriTemplate, + previewUriTemplate, + templateVariables, +} from "../../../utils/uriTemplate"; export interface ResourceTemplatePanelProps { template: ResourceTemplate; @@ -39,34 +44,6 @@ export interface ResourceTemplatePanelProps { const COMPLETION_DEBOUNCE_MS = 300; -function parseVariableNames(uriTemplate: string): string[] { - const names: string[] = []; - const regex = /\{(\w+)\}/g; - let match: RegExpExecArray | null; - - while ((match = regex.exec(uriTemplate)) !== null) { - names.push(match[1]); - } - - return names; -} - -function resolveUri( - uriTemplate: string, - variables: Record<string, string>, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (_, key: string) => variables[key]); -} - -function previewUri( - uriTemplate: string, - variables: Record<string, string>, -): string { - return uriTemplate.replace(/\{(\w+)\}/g, (match, key: string) => - variables[key]?.length > 0 ? variables[key] : match, - ); -} - const HeaderRow = Group.withProps({ justify: "space-between", wrap: "nowrap", @@ -108,10 +85,16 @@ export function ResourceTemplatePanel({ }: ResourceTemplatePanelProps) { const { name, title, uriTemplate, description, annotations } = template; - const variableNames = useMemo( - () => parseVariableNames(uriTemplate), + // Every variable the template declares, with the operator it appears under + // and whether omitting it would change the URI's shape (see `utils/uriTemplate`). + const declaredVariables = useMemo( + () => templateVariables(uriTemplate), [uriTemplate], ); + const variableNames = useMemo( + () => declaredVariables.map((v) => v.name), + [declaredVariables], + ); const [variables, setVariables] = useState<Record<string, string>>(() => Object.fromEntries(variableNames.map((n) => [n, ""])), @@ -232,13 +215,18 @@ export function ResourceTemplatePanel({ void runCompletion(varName, value, buildContext(varName)); } - const canSubmit = variableNames.every((n) => variables[n]?.length > 0); + // Only the variables whose absence would change the URI's shape gate the + // read; an unfilled `{?topic}` is a legitimate request for the unfiltered + // resource, and RFC 6570 drops the whole expression for it. + const canSubmit = declaredVariables.every( + (v) => !v.required || variables[v.name]?.length > 0, + ); function handleSubmit() { - onReadResource(resolveUri(uriTemplate, variables)); + onReadResource(expandUriTemplate(uriTemplate, variables)); } - const preview = previewUri(uriTemplate, variables); + const preview = previewUriTemplate(uriTemplate, variables); return ( <Stack gap="md"> @@ -251,13 +239,17 @@ export function ResourceTemplatePanel({ </HeaderRow> {description && <DescriptionText>{description}</DescriptionText>} <Stack gap="sm"> - {variableNames.map((varName) => { + {declaredVariables.map(({ name: varName, required }) => { /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ const fieldValue = variables[varName] ?? ""; + // RFC 6570 omits an undefined variable under a query/path-segment + // operator entirely, so those fields are genuinely optional. + const description = required ? undefined : "Optional"; return useAutocomplete ? ( <Autocomplete key={varName} label={varName} + description={description} placeholder={`Enter ${varName}`} value={fieldValue} data={completions[varName] ?? []} @@ -273,6 +265,7 @@ export function ResourceTemplatePanel({ <TextInput key={varName} label={varName} + description={description} placeholder={`Enter ${varName}`} value={fieldValue} onChange={(e) => diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts new file mode 100644 index 00000000..b337a42b --- /dev/null +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { + expandUriTemplate, + parseUriTemplate, + previewUriTemplate, + templateVariables, +} from "./uriTemplate"; + +describe("parseUriTemplate", () => { + it("splits literals from expressions", () => { + expect(parseUriTemplate("foobar://events/{topic}")).toEqual([ + { kind: "literal", text: "foobar://events/" }, + { + kind: "expression", + source: "{topic}", + operator: "", + names: ["topic"], + }, + ]); + }); + + it("reads the operator and the comma-separated name list", () => { + expect(parseUriTemplate("x://{?a,b*}")).toEqual([ + { kind: "literal", text: "x://" }, + { + kind: "expression", + source: "{?a,b*}", + operator: "?", + names: ["a", "b"], + }, + ]); + }); + + it("treats an unclosed expression as trailing literal text", () => { + expect(parseUriTemplate("x://a/{oops")).toEqual([ + { kind: "literal", text: "x://a/{oops" }, + ]); + }); + + it("returns nothing for an empty template", () => { + expect(parseUriTemplate("")).toEqual([]); + }); +}); + +describe("templateVariables", () => { + it("finds a simple variable and marks it required", () => { + expect(templateVariables("foobar://events/{topic}")).toEqual([ + { name: "topic", operator: "", required: true }, + ]); + }); + + it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { + expect(templateVariables("foobar://events{?topic}")).toEqual([ + { name: "topic", operator: "?", required: false }, + ]); + }); + + it.each([ + ["{+path}", "+", true], + ["{#frag}", "#", true], + ["{.label}", ".", false], + ["{/segment}", "/", false], + ["{&extra}", "&", false], + ])("classifies %s", (expression, operator, required) => { + const [variable] = templateVariables(`x://a${expression}`); + expect(variable.operator).toBe(operator); + expect(variable.required).toBe(required); + }); + + it("deduplicates a repeated name and keeps it required if any use is", () => { + expect(templateVariables("x://{?id}/{id}")).toEqual([ + { name: "id", operator: "?", required: true }, + ]); + }); + + it("returns an empty list for a template with no expressions", () => { + expect(templateVariables("file:///static.txt")).toEqual([]); + }); +}); + +describe("expandUriTemplate", () => { + it("percent-encodes a reserved character in a simple variable (#1919)", () => { + expect( + expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }), + ).toBe("foobar://events/foo%2Fbar"); + }); + + it.each([ + ["?", "a?b", "a%3Fb"], + ["#", "a#b", "a%23b"], + ["%", "a%b", "a%25b"], + ["space", "a b", "a%20b"], + ["unicode", "caffè", "caff%C3%A8"], + ])("encodes %s", (_label, value, encoded) => { + expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`); + }); + + it("builds an encoded query expression", () => { + expect( + expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }), + ).toBe("foobar://events?topic=foo%2Fbar"); + }); + + it("leaves reserved characters intact under the + operator", () => { + expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b"); + }); + + it("omits an expression whose variable was left blank", () => { + expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe( + "foobar://events", + ); + }); + + it("joins two query expressions with & rather than a second ?", () => { + expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe( + "x://a?one=1&two=2", + ); + }); + + it("falls back to the raw template when the SDK cannot parse it", () => { + expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops"); + }); +}); + +describe("previewUriTemplate", () => { + it("shows the template verbatim before anything is entered", () => { + expect(previewUriTemplate("file:///users/{userId}/profile", {})).toBe( + "file:///users/{userId}/profile", + ); + }); + + it("substitutes only the expressions that are filled", () => { + expect( + previewUriTemplate("db://{tableName}/rows/{rowId}", { + tableName: "users", + rowId: "", + }), + ).toBe("db://users/rows/{rowId}"); + }); + + it("encodes the filled values the same way expansion does", () => { + expect( + previewUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }), + ).toBe("foobar://events/foo%2Fbar"); + }); + + it("keeps a multi-name expression whole until every name is filled", () => { + expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe( + "x://a{?one,two}", + ); + }); + + it("still rewrites the second ? to & when both query expressions resolve", () => { + expect( + previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }), + ).toBe("x://a?one=1&two=2"); + }); + + it("restores a deferred expression that follows a resolved query expression", () => { + expect(previewUriTemplate("x://a{?one}{?two}", { one: "1" })).toBe( + "x://a?one=1{?two}", + ); + }); + + it("falls back to the raw template when the SDK cannot parse it", () => { + expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops"); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts new file mode 100644 index 00000000..25cd027e --- /dev/null +++ b/clients/web/src/utils/uriTemplate.ts @@ -0,0 +1,219 @@ +/** + * RFC 6570 URI Template support for the Resources screen. + * + * The web client used to discover and substitute variables with a bare + * `/\{(\w+)\}/g` regex, which is wrong in two ways (#1919): it cannot see an + * expression carrying an operator (`{?topic}`, `{/path}`, `{#frag}`, ...), so + * no input is rendered for it; and it splices the raw value in, so a `/`, `?`, + * `#` or space in a simple `{var}` lands unencoded and silently changes the + * URI's structure. + * + * Expansion itself is delegated to the SDK's `UriTemplate` -- the same + * implementation `InspectorClient.readResourceFromTemplate` (and therefore the + * TUI) already expands through, so the two clients cannot disagree about what a + * template means. What lives here is the surrounding form/preview logic the SDK + * class does not provide: which variables to render an input for, which of them + * a read cannot proceed without, and a partially-expanded preview string. + */ + +import { UriTemplate } from "@modelcontextprotocol/client"; + +/** The RFC 6570 operators, in the order the SDK's parser tests for them. */ +const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const; + +/** + * Operators whose expansion omits cleanly when the variables in it are + * undefined -- the whole expression, separator included, simply disappears. + * A variable under any *other* operator is interpolated into the middle of the + * URI, so leaving it out produces a different resource path rather than a + * shorter one; those we require before allowing a read. + */ +const OMITTABLE_OPERATORS = new Set([".", "/", "?", "&"]); + +interface TemplateLiteral { + kind: "literal"; + text: string; +} + +interface TemplateExpression { + kind: "expression"; + /** The expression including its braces, e.g. `{?topic}`. */ + source: string; + /** The RFC 6570 operator, or `""` for a simple expression. */ + operator: string; + /** Variable names in the expression, `*` (explode) and whitespace stripped. */ + names: string[]; +} + +export type TemplatePart = TemplateLiteral | TemplateExpression; + +export interface TemplateVariable { + name: string; + /** The operator of the expression the variable was first seen in. */ + operator: string; + /** + * True when omitting the variable would change the URI's structure rather + * than shorten it -- see {@link OMITTABLE_OPERATORS}. + */ + required: boolean; +} + +/** + * Splits a template into literal runs and expressions. + * + * Deliberately mirrors the SDK parser's own scanning rules (first matching + * operator character wins, names split on `,`, `*` stripped) so this never + * disagrees with the class that ultimately does the expanding. An unclosed + * `{` yields a trailing literal, which is what makes the callers below degrade + * to "render no inputs, show the template verbatim" rather than throw. + */ +export function parseUriTemplate(uriTemplate: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let literal = ""; + let i = 0; + + while (i < uriTemplate.length) { + if (uriTemplate[i] !== "{") { + literal += uriTemplate[i]; + i += 1; + continue; + } + const end = uriTemplate.indexOf("}", i); + if (end === -1) { + // Unclosed expression -- the rest is not a template, treat it as text. + literal += uriTemplate.slice(i); + break; + } + if (literal) { + parts.push({ kind: "literal", text: literal }); + literal = ""; + } + const body = uriTemplate.slice(i + 1, end); + const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; + const names = body + .slice(operator.length) + .split(",") + .map((name) => name.replace("*", "").trim()) + .filter((name) => name.length > 0); + parts.push({ + kind: "expression", + source: uriTemplate.slice(i, end + 1), + operator, + names, + }); + i = end + 1; + } + + if (literal) parts.push({ kind: "literal", text: literal }); + return parts; +} + +/** + * The variables a form should render an input for, in template order and + * deduplicated by name. A name appearing under more than one operator is + * required if *any* of its occurrences is. + */ +export function templateVariables(uriTemplate: string): TemplateVariable[] { + const byName = new Map<string, TemplateVariable>(); + + for (const part of parseUriTemplate(uriTemplate)) { + if (part.kind !== "expression") continue; + const required = !OMITTABLE_OPERATORS.has(part.operator); + for (const name of part.names) { + const existing = byName.get(name); + if (existing) { + existing.required = existing.required || required; + } else { + byName.set(name, { name, operator: part.operator, required }); + } + } + } + + return [...byName.values()]; +} + +/** + * Drops empty entries so an untouched optional field reads as *undefined* to + * the SDK (the expression disappears) rather than as the empty string (which + * would expand to a valueless `?topic=`). + */ +function definedValues(values: Record<string, string>): Record<string, string> { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => value.length > 0), + ); +} + +/** + * Expands a template against the entered values per RFC 6570 -- percent- + * encoding each value according to its operator, and omitting expressions whose + * variables were left blank. + * + * A template the SDK refuses to parse falls back to the raw template string, + * which is what the user already sees in the preview and what the server will + * reject with a legible error; throwing here would take out the whole panel. + */ +export function expandUriTemplate( + uriTemplate: string, + values: Record<string, string>, +): string { + try { + return new UriTemplate(uriTemplate).expand(definedValues(values)); + } catch { + return uriTemplate; + } +} + +/** + * A placeholder standing in for an expression the user has not filled in yet. + * + * `U+0000` cannot appear in a URI template, so a token built from it can never + * collide with real template text; and because it is emitted as *literal* text + * rather than as a variable value, expansion passes it through unencoded. + */ +const deferredToken = (index: number) => `\u0000${index}\u0000`; + +/** + * A partially-expanded template for display: expressions whose variables are + * all filled are expanded exactly as {@link expandUriTemplate} would, and the + * rest are left standing as written so the user can see what is still needed. + * + * Unfilled expressions are swapped for an inert token and restored after + * expansion -- rather than expanding each filled expression in isolation -- so + * the SDK still sees one whole template and applies its cross-expression rules + * (notably rewriting a second `?` query expression to `&`). + */ +export function previewUriTemplate( + uriTemplate: string, + values: Record<string, string>, +): string { + const parts = parseUriTemplate(uriTemplate); + const deferred: string[] = []; + const defined = definedValues(values); + + const rewritten = parts + .map((part) => { + if (part.kind === "literal") return part.text; + if (part.names.every((name) => defined[name] !== undefined)) { + return part.source; + } + deferred.push(part.source); + return deferredToken(deferred.length - 1); + }) + .join(""); + + let expanded: string; + try { + expanded = new UriTemplate(rewritten).expand(defined); + } catch { + return uriTemplate; + } + + // Restored by exact-string replacement rather than by a pattern: a regex + // matching the token would have to embed U+0000 literally, which `eslint` + // rejects (`no-control-regex`) -- and each token's text is already known + // here, so there is nothing to match on. + return deferred.reduce( + (uri, source, index) => uri.replaceAll(deferredToken(index), source), + expanded, + ); +} diff --git a/test-servers/configs/rfc6570-templates-http.json b/test-servers/configs/rfc6570-templates-http.json new file mode 100644 index 00000000..c6949773 --- /dev/null +++ b/test-servers/configs/rfc6570-templates-http.json @@ -0,0 +1,12 @@ +{ + "serverInfo": { + "name": "rfc6570-template-showcase", + "version": "1.0.0" + }, + "resourceTemplates": [{ "preset": "rfc6570_templates" }], + "tools": [{ "preset": "echo" }], + "transport": { + "type": "streamable-http", + "port": 6603 + } +} diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index d78998ec..34fd53ad 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -62,6 +62,7 @@ import { createFileResourceTemplate, createUserResourceTemplate, createNumberedResourceTemplates, + createRfc6570ResourceTemplates, createSimplePrompt, createArgsPrompt, createNumberedPrompts, @@ -260,6 +261,8 @@ function resolveResourceTemplatePreset( return createUserResourceTemplate(); case "numbered_resource_templates": return createNumberedResourceTemplates(Number(get("count")) || 3); + case "rfc6570_templates": + return createRfc6570ResourceTemplates(); default: throw new Error(`Unknown resource template preset: ${name}`); } diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 070b5750..89283e49 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1474,6 +1474,53 @@ export function createFileResourceTemplate( }; } +/** + * Resource templates that exercise RFC 6570 expansion (#1919). + * + * `events_by_topic` is the simple `{topic}` expression from the issue: a `/`, + * `?`, `#` or space in the value MUST be percent-encoded, or the URI gains a + * path segment and a conforming matcher rejects it. `events_by_query` is the + * `{?topic}` form, which the web client could not even render an input for. + * + * Both handlers echo the URI they were matched against plus the variables the + * server decoded, so the round-trip is visible in the read result. + */ +export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] { + const echo = + (label: string) => async (uri: URL, params: Record<string, unknown>) => ({ + contents: [ + { + uri: uri.toString(), + mimeType: "application/json", + text: JSON.stringify( + { template: label, matchedUri: uri.toString(), variables: params }, + null, + 2, + ), + }, + ], + }); + + return [ + { + name: "events_by_topic", + uriTemplate: "foobar://events/{topic}", + description: + "Simple expression - a reserved character in `topic` must be percent-encoded", + inputSchema: { topic: z.string().describe("Topic name") }, + handler: echo("foobar://events/{topic}"), + }, + { + name: "events_by_query", + uriTemplate: "foobar://events{?topic}", + description: + "Query expression - optional, and omitted entirely when `topic` is blank", + inputSchema: { topic: z.string().describe("Topic name") }, + handler: echo("foobar://events{?topic}"), + }, + ]; +} + /** * Create a "user" resource template that returns user data by ID */ From f5757c0f950288880f6f75bfbf4846006eabcbde Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:41:36 -0400 Subject: [PATCH 174/941] test(integration): drive the mrtr_empty reproduction through the showcase config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: the unit test covered the panel's rendering, but nothing executed `mrtr_empty`, so the handler, its preset-registry branch, and the showcase config entry could regress with CI green. Adds a live modern-era test that loads `mrtr-showcase-http.json` through `loadConfig`/`resolveConfig` — the same artifacts the README's manual reproduction uses, so the config entry and the registry branch are covered rather than just the fixture factory — answers the embedded elicitation, and asserts the terminal result really is an empty `CallToolResult`: that is the input the Tools panel must render as "completed, returned nothing". Signed-off-by: cliffhall <cliff@futurescale.com> --- .../mcp/inspectorClient-modern-era.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 6251340d..cb346ec0 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -18,6 +18,8 @@ import { createMrtrSamplingTool, createMrtrLoopTool, createMrtrEdgeCaseTool, + loadConfig, + resolveConfig, } from "@modelcontextprotocol/inspector-test-server"; import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server"; import type { @@ -26,6 +28,13 @@ import type { } from "@modelcontextprotocol/client"; import { LOG_LEVEL_META_KEY } from "@modelcontextprotocol/client"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// clients/web/src/test/integration/mcp → repo root is six levels up. Derived +// from the module URL rather than `process.cwd()` so the showcase config +// resolves the same however vitest is invoked. +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../".repeat(6)); /** * Live coverage of the modern (2026-07-28) connection path (#1700). The bundled @@ -389,6 +398,45 @@ describe("modern-era negotiation (2026-07-28)", () => { expect(Object.keys(retry2.inputResponses ?? {})).toHaveLength(0); }); + // #1860. Drives the reproduction the README documents, through the SAME + // artifacts a human would use — the showcase config file, resolved through the + // preset registry — so the config entry, the `mrtr_empty` registry branch, and + // the handler are all covered rather than just the fixture factory. The + // assertion is that a completed MRTR call really does hand back an empty + // `CallToolResult`: that is the input the Tools panel has to render as + // "completed, returned nothing" instead of its pre-run placeholder. + it("completes an MRTR sequence with an empty result (mrtr_empty, via the showcase config)", async () => { + const config = loadConfig( + join(repoRoot, "test-servers/configs/mrtr-showcase-http.json"), + ); + expect(config.tools).toContainEqual({ preset: "mrtr_empty" }); + + const started = createTestServerHttp(resolveConfig(config)); + await started.start(); + server = started; + const connected = await connectWithEra(started.url, "modern"); + + let pausedAtPendingUi = false; + connected.addEventListener("newPendingElicitation", (event) => { + pausedAtPendingUi = true; + void event.detail.respond({ + action: "accept", + content: { ack: true }, + }); + }); + + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "mrtr_empty"); + expect(tool).toBeDefined(); + + const result = await connected.callTool(tool!, {}); + expect(result.success).toBe(true); + expect(pausedAtPendingUi).toBe(true); + expect(result.result!.content).toEqual([]); + expect(result.result!.structuredContent).toBeUndefined(); + expect(result.result!.isError).toBeFalsy(); + }); + it("cancels an in-flight MRTR call while its embedded request is pending", async () => { const started = await startMrtrServer(createMrtrTool()); const connected = await connectWithEra(started.url, "modern"); From 409dce37be4647bef46f2df75972b876804fa897 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 18:51:12 -0400 Subject: [PATCH 175/941] fix(core): share URI-template expansion and correct the SDK's multi-name branch Addresses Copilot's review on #2035. All three findings reproduced against the pinned SDK before acting. 1. `#` was misclassified as required. Measured: `x://a{#frag}` with no `frag` expands to exactly `x://a`, a well-formed URI naming a real resource -- unlike `{+path}` (`x://a/`) or a simple `{userId}` (`file:///users//profile`), which leave an empty path segment. Moved `#` into the omittable set; the required cases now assert what the URI *becomes* when blank, so the rule is checked rather than asserted. 2. Delegating to the SDK did not actually give RFC 6570 expansion. `UriTemplate.expandPart` takes an early `names.length > 1` branch that raw-joins values, skipping both `encodeValue` and the operator prefix: `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` -- the very unencoded-slash defect this PR is about -- and `x://a{/p,q}` to `x://ax y,z`. Only `?`/`&` are correct, being dispatched earlier. Fixing that in the web client alone would have left the TUI and CLI wrong, since `readResourceFromTemplate` expands through the same class. So parse/classify/expand now live in `core/mcp/uriTemplate.ts` and both call sites use it. The correction is surgical: a multi-name non-query expression is expanded here and spliced in as literal text before the SDK sees the template (safe -- both encoders escape `{`/`}`), while every single-name and query expression still goes through the SDK untouched. The preview applies the same correction, so it cannot promise a URI that submitting would not send. 3. The showcase promised a blank `{?topic}` read that did not work. `UriTemplate.match()` compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the read 404s. A real server exposes the unfiltered collection as its own resource; the showcase now registers `foobar://events` so the documented step resolves. Verified end to end through the CLI. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 9 +- README.md | 9 +- .../web/src/test/core/mcp/uriTemplate.test.ts | 194 +++++++++++++ clients/web/src/utils/uriTemplate.test.ts | 133 +-------- clients/web/src/utils/uriTemplate.ts | 187 ++----------- core/mcp/inspectorClient.ts | 20 +- core/mcp/uriTemplate.ts | 257 ++++++++++++++++++ .../configs/rfc6570-templates-http.json | 19 +- test-servers/src/preset-registry.ts | 3 + test-servers/src/test-server-fixtures.ts | 21 ++ 10 files changed, 559 insertions(+), 293 deletions(-) create mode 100644 clients/web/src/test/core/mcp/uriTemplate.test.ts create mode 100644 core/mcp/uriTemplate.ts diff --git a/AGENTS.md b/AGENTS.md index d07e84f4..5005d6d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,14 @@ v2/main/ │ │ # a nullable field entirely — #1928/#2015) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores -│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks +│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand +│ │ # shared by the web Resources form and +│ │ # readResourceFromTemplate (TUI + CLI), so the +│ │ # clients cannot drift on what a template means; +│ │ # delegates to the SDK's UriTemplate but corrects +│ │ # its multi-name `{a,b}` branch, which skips both +│ │ # encoding and the operator prefix — #1919; +│ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; │ │ # listSalvage.ts: per-item salvage for list results — diff --git a/README.md b/README.md index de94130e..0bc30868 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ inspector/ │ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union │ │ # schema collapse shared by the web and TUI form builders │ ├── logging/ # Silent pino logger singleton -│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import +│ ├── mcp/ # InspectorClient runtime, state stores, transports, config import, +│ │ # and the RFC 6570 URI-template helpers all three clients expand through │ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends @@ -240,13 +241,15 @@ The **TUI** had the same gap and is worth checking against the same server (`--t #### RFC 6570 resource templates -`rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against and the variables the server decoded. Plain streamable-HTTP; connect with the **default (legacy)** protocol era. +`rfc6570-templates-http.json` serves two resource templates straight out of [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) — `events_by_topic` (`foobar://events/{topic}`) and `events_by_query` (`foobar://events{?topic}`) — each echoing the URI it was matched against, plus a plain `foobar://events` resource (see below). Plain streamable-HTTP; connect with the **default (legacy)** protocol era. Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The request must go out as `foobar://events/foo%2Fbar`, and the result echoes back the URI the server matched. On the broken build the value was spliced in raw, so the slash created a second path segment and the SDK's matcher answered `-32602 Resource not found: foobar://events/foo/bar` — the exact failure in the issue. The same holds for `?`, `#`, `%`, spaces, and non-ASCII text. **events_by_query** is the half that was invisible: the old `/\{(\w+)\}/g` scan could not see an expression carrying an operator, so no `topic` input was rendered at all. It now appears, marked **Optional** — RFC 6570 drops the whole expression when the variable is undefined, so reading with the field blank requests `foobar://events`, and filling it in requests `foobar://events?topic=foo%2Fbar`. The URI preview beside the title shows the partially-expanded form as you type, leaving unfilled expressions standing as written. -Both clients expand through the SDK's `UriTemplate` — the web client via [`clients/web/src/utils/uriTemplate.ts`](./clients/web/src/utils/uriTemplate.ts), the TUI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. +> The plain `foobar://events` resource is registered deliberately, not as filler. The SDK's `UriTemplate.match()` compiles `{?topic}` to a **required** `\?topic=([^&]+)`, so a template alone cannot serve the blank read — `match("foobar://events")` returns `null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves. + +All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` with one correction applied first: the SDK's `expandPart` takes an early `names.length > 1` branch that raw-joins the values, skipping both percent-encoding **and** the operator prefix, so `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` and `x://a{/p,q}` to `x://ax y,z`. Those expressions are expanded by the helper and spliced in as literal text before the SDK sees them; every single-name and query expression still goes through the SDK untouched. #### Advertised extensions diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts new file mode 100644 index 00000000..570caffc --- /dev/null +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -0,0 +1,194 @@ +import { describe, it, expect } from "vitest"; +import { + expandUriTemplate, + parseUriTemplate, + templateVariables, +} from "@inspector/core/mcp/uriTemplate.js"; + +describe("parseUriTemplate", () => { + it("splits literals from expressions", () => { + expect(parseUriTemplate("foobar://events/{topic}")).toEqual([ + { kind: "literal", text: "foobar://events/" }, + { + kind: "expression", + source: "{topic}", + operator: "", + names: ["topic"], + }, + ]); + }); + + it("reads the operator and the comma-separated name list", () => { + expect(parseUriTemplate("x://{?a,b*}")).toEqual([ + { kind: "literal", text: "x://" }, + { + kind: "expression", + source: "{?a,b*}", + operator: "?", + names: ["a", "b"], + }, + ]); + }); + + it("treats an unclosed expression as trailing literal text", () => { + expect(parseUriTemplate("x://a/{oops")).toEqual([ + { kind: "literal", text: "x://a/{oops" }, + ]); + }); + + it("returns nothing for an empty template", () => { + expect(parseUriTemplate("")).toEqual([]); + }); +}); + +describe("templateVariables", () => { + it("finds a simple variable and marks it required", () => { + expect(templateVariables("foobar://events/{topic}")).toEqual([ + { name: "topic", operator: "", required: true }, + ]); + }); + + it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { + expect(templateVariables("foobar://events{?topic}")).toEqual([ + { name: "topic", operator: "?", required: false }, + ]); + }); + + // Required iff omitting the variable leaves an empty slot mid-URI rather + // than a shorter, still-well-formed URI. Verified against the pinned SDK: + // `x://a/{+path}` with no `path` expands to "x://a/" (empty segment), while + // `x://a{#frag}` expands to exactly "x://a". + it.each([ + ["{+path}", "+", true], + ["{#frag}", "#", false], + ["{.label}", ".", false], + ["{/segment}", "/", false], + ["{&extra}", "&", false], + ])("classifies %s", (expression, operator, required) => { + const [variable] = templateVariables(`x://a${expression}`); + expect(variable.operator).toBe(operator); + expect(variable.required).toBe(required); + }); + + it("deduplicates a repeated name and keeps it required if any use is", () => { + expect(templateVariables("x://{?id}/{id}")).toEqual([ + { name: "id", operator: "?", required: true }, + ]); + }); + + it.each([ + ["file:///users/{userId}/profile", "file:///users//profile"], + ["x://a/{+path}", "x://a/"], + ])( + "requires %s because omitting it leaves an empty slot (%s)", + (template, omitted) => { + expect(templateVariables(template)[0].required).toBe(true); + // The reason, asserted rather than asserted-about: this is what the URI + // would become if the field were left blank. + expect(expandUriTemplate(template, {})).toBe(omitted); + }, + ); + + it("does not require {#frag}, which omits to a well-formed URI", () => { + expect(templateVariables("x://a{#frag}")[0].required).toBe(false); + expect(expandUriTemplate("x://a{#frag}", {})).toBe("x://a"); + }); + + it("returns an empty list for a template with no expressions", () => { + expect(templateVariables("file:///static.txt")).toEqual([]); + }); +}); + +describe("expandUriTemplate", () => { + it("percent-encodes a reserved character in a simple variable (#1919)", () => { + expect( + expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }), + ).toBe("foobar://events/foo%2Fbar"); + }); + + it.each([ + ["?", "a?b", "a%3Fb"], + ["#", "a#b", "a%23b"], + ["%", "a%b", "a%25b"], + ["space", "a b", "a%20b"], + ["unicode", "caffè", "caff%C3%A8"], + ])("encodes %s", (_label, value, encoded) => { + expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`); + }); + + it("builds an encoded query expression", () => { + expect( + expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }), + ).toBe("foobar://events?topic=foo%2Fbar"); + }); + + it("leaves reserved characters intact under the + operator", () => { + expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b"); + }); + + it("omits an expression whose variable was left blank", () => { + expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe( + "foobar://events", + ); + }); + + it("joins two query expressions with & rather than a second ?", () => { + expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe( + "x://a?one=1&two=2", + ); + }); + + it("falls back to the raw template when the SDK cannot parse it", () => { + expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops"); + }); +}); + +describe("expandUriTemplate - multi-name expressions (SDK correction)", () => { + // The pinned SDK's `expandPart` takes an early `names.length > 1` branch that + // raw-joins the values, skipping BOTH encodeValue and the operator prefix. + // Measured directly: `x://{a,b}` -> "x://foo/bar,q", `x://a{/p,q}` -> + // "x://ax y,z". These assert the corrected output. + it("encodes each value in a simple multi-name expression", () => { + expect(expandUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe( + "x://foo%2Fbar,q", + ); + }); + + it("keeps the / operator prefix and separator, and encodes", () => { + expect(expandUriTemplate("x://a{/p,q}", { p: "x y", q: "z" })).toBe( + "x://a/x%20y/z", + ); + }); + + it("keeps the . operator prefix and separator", () => { + expect(expandUriTemplate("x://a{.p,q}", { p: "x/y", q: "z" })).toBe( + "x://a.x%2Fy.z", + ); + }); + + it("keeps the # prefix and leaves reserved characters under it", () => { + expect(expandUriTemplate("x://a{#p,q}", { p: "x/y", q: "z" })).toBe( + "x://a#x/y,z", + ); + }); + + it("leaves reserved characters under the + operator", () => { + expect(expandUriTemplate("x://{+a,b}", { a: "x/y", b: "z" })).toBe( + "x://x/y,z", + ); + }); + + it("drops only the undefined names, keeping the rest", () => { + expect(expandUriTemplate("x://a{/p,q}", { q: "z" })).toBe("x://a/z"); + }); + + it("omits the whole expression when no name has a value", () => { + expect(expandUriTemplate("x://a{/p,q}", {})).toBe("x://a"); + }); + + it("leaves multi-name query expressions to the SDK, which handles them", () => { + expect(expandUriTemplate("x://a{?p,q}", { p: "x/y", q: "z" })).toBe( + "x://a?p=x%2Fy&q=z", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index b337a42b..7a1ead92 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -1,126 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - expandUriTemplate, - parseUriTemplate, - previewUriTemplate, - templateVariables, -} from "./uriTemplate"; - -describe("parseUriTemplate", () => { - it("splits literals from expressions", () => { - expect(parseUriTemplate("foobar://events/{topic}")).toEqual([ - { kind: "literal", text: "foobar://events/" }, - { - kind: "expression", - source: "{topic}", - operator: "", - names: ["topic"], - }, - ]); - }); - - it("reads the operator and the comma-separated name list", () => { - expect(parseUriTemplate("x://{?a,b*}")).toEqual([ - { kind: "literal", text: "x://" }, - { - kind: "expression", - source: "{?a,b*}", - operator: "?", - names: ["a", "b"], - }, - ]); - }); - - it("treats an unclosed expression as trailing literal text", () => { - expect(parseUriTemplate("x://a/{oops")).toEqual([ - { kind: "literal", text: "x://a/{oops" }, - ]); - }); - - it("returns nothing for an empty template", () => { - expect(parseUriTemplate("")).toEqual([]); - }); -}); - -describe("templateVariables", () => { - it("finds a simple variable and marks it required", () => { - expect(templateVariables("foobar://events/{topic}")).toEqual([ - { name: "topic", operator: "", required: true }, - ]); - }); - - it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { - expect(templateVariables("foobar://events{?topic}")).toEqual([ - { name: "topic", operator: "?", required: false }, - ]); - }); - - it.each([ - ["{+path}", "+", true], - ["{#frag}", "#", true], - ["{.label}", ".", false], - ["{/segment}", "/", false], - ["{&extra}", "&", false], - ])("classifies %s", (expression, operator, required) => { - const [variable] = templateVariables(`x://a${expression}`); - expect(variable.operator).toBe(operator); - expect(variable.required).toBe(required); - }); - - it("deduplicates a repeated name and keeps it required if any use is", () => { - expect(templateVariables("x://{?id}/{id}")).toEqual([ - { name: "id", operator: "?", required: true }, - ]); - }); - - it("returns an empty list for a template with no expressions", () => { - expect(templateVariables("file:///static.txt")).toEqual([]); - }); -}); - -describe("expandUriTemplate", () => { - it("percent-encodes a reserved character in a simple variable (#1919)", () => { - expect( - expandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }), - ).toBe("foobar://events/foo%2Fbar"); - }); - - it.each([ - ["?", "a?b", "a%3Fb"], - ["#", "a#b", "a%23b"], - ["%", "a%b", "a%25b"], - ["space", "a b", "a%20b"], - ["unicode", "caffè", "caff%C3%A8"], - ])("encodes %s", (_label, value, encoded) => { - expect(expandUriTemplate("x://{v}", { v: value })).toBe(`x://${encoded}`); - }); - - it("builds an encoded query expression", () => { - expect( - expandUriTemplate("foobar://events{?topic}", { topic: "foo/bar" }), - ).toBe("foobar://events?topic=foo%2Fbar"); - }); - - it("leaves reserved characters intact under the + operator", () => { - expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b"); - }); - - it("omits an expression whose variable was left blank", () => { - expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe( - "foobar://events", - ); - }); - - it("joins two query expressions with & rather than a second ?", () => { - expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe( - "x://a?one=1&two=2", - ); - }); - - it("falls back to the raw template when the SDK cannot parse it", () => { - expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops"); - }); -}); +import { previewUriTemplate } from "./uriTemplate"; describe("previewUriTemplate", () => { it("shows the template verbatim before anything is entered", () => { @@ -166,3 +45,13 @@ describe("previewUriTemplate", () => { expect(previewUriTemplate("x://a/{oops", {})).toBe("x://a/{oops"); }); }); + +describe("previewUriTemplate - multi-name expressions", () => { + it("applies the same correction the real expansion does", () => { + // Must match expandUriTemplate("x://{a,b}", ...) exactly, or the preview + // would promise a URI that submitting does not send. + expect(previewUriTemplate("x://{a,b}", { a: "foo/bar", b: "q" })).toBe( + "x://foo%2Fbar,q", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 25cd027e..4c3f8ea2 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -1,167 +1,29 @@ /** - * RFC 6570 URI Template support for the Resources screen. + * The web Resources form's view of an RFC 6570 URI template (#1919). * - * The web client used to discover and substitute variables with a bare - * `/\{(\w+)\}/g` regex, which is wrong in two ways (#1919): it cannot see an - * expression carrying an operator (`{?topic}`, `{/path}`, `{#frag}`, ...), so - * no input is rendered for it; and it splices the raw value in, so a `/`, `?`, - * `#` or space in a simple `{var}` lands unencoded and silently changes the - * URI's structure. - * - * Expansion itself is delegated to the SDK's `UriTemplate` -- the same - * implementation `InspectorClient.readResourceFromTemplate` (and therefore the - * TUI) already expands through, so the two clients cannot disagree about what a - * template means. What lives here is the surrounding form/preview logic the SDK - * class does not provide: which variables to render an input for, which of them - * a read cannot proceed without, and a partially-expanded preview string. + * Parsing, variable classification, and expansion live in + * `@inspector/core/mcp/uriTemplate.js` so the web form, the TUI, and the CLI + * cannot disagree about what a template means -- they are re-exported here so + * the panel has a single import. What this module adds is the one piece that is + * purely a display concern: the partially-expanded preview string. */ +import { + applyMultiNameCorrection, + definedValues, + parseUriTemplate, +} from "@inspector/core/mcp/uriTemplate.js"; import { UriTemplate } from "@modelcontextprotocol/client"; -/** The RFC 6570 operators, in the order the SDK's parser tests for them. */ -const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const; - -/** - * Operators whose expansion omits cleanly when the variables in it are - * undefined -- the whole expression, separator included, simply disappears. - * A variable under any *other* operator is interpolated into the middle of the - * URI, so leaving it out produces a different resource path rather than a - * shorter one; those we require before allowing a read. - */ -const OMITTABLE_OPERATORS = new Set([".", "/", "?", "&"]); - -interface TemplateLiteral { - kind: "literal"; - text: string; -} - -interface TemplateExpression { - kind: "expression"; - /** The expression including its braces, e.g. `{?topic}`. */ - source: string; - /** The RFC 6570 operator, or `""` for a simple expression. */ - operator: string; - /** Variable names in the expression, `*` (explode) and whitespace stripped. */ - names: string[]; -} - -export type TemplatePart = TemplateLiteral | TemplateExpression; - -export interface TemplateVariable { - name: string; - /** The operator of the expression the variable was first seen in. */ - operator: string; - /** - * True when omitting the variable would change the URI's structure rather - * than shorten it -- see {@link OMITTABLE_OPERATORS}. - */ - required: boolean; -} - -/** - * Splits a template into literal runs and expressions. - * - * Deliberately mirrors the SDK parser's own scanning rules (first matching - * operator character wins, names split on `,`, `*` stripped) so this never - * disagrees with the class that ultimately does the expanding. An unclosed - * `{` yields a trailing literal, which is what makes the callers below degrade - * to "render no inputs, show the template verbatim" rather than throw. - */ -export function parseUriTemplate(uriTemplate: string): TemplatePart[] { - const parts: TemplatePart[] = []; - let literal = ""; - let i = 0; - - while (i < uriTemplate.length) { - if (uriTemplate[i] !== "{") { - literal += uriTemplate[i]; - i += 1; - continue; - } - const end = uriTemplate.indexOf("}", i); - if (end === -1) { - // Unclosed expression -- the rest is not a template, treat it as text. - literal += uriTemplate.slice(i); - break; - } - if (literal) { - parts.push({ kind: "literal", text: literal }); - literal = ""; - } - const body = uriTemplate.slice(i + 1, end); - const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; - const names = body - .slice(operator.length) - .split(",") - .map((name) => name.replace("*", "").trim()) - .filter((name) => name.length > 0); - parts.push({ - kind: "expression", - source: uriTemplate.slice(i, end + 1), - operator, - names, - }); - i = end + 1; - } - - if (literal) parts.push({ kind: "literal", text: literal }); - return parts; -} - -/** - * The variables a form should render an input for, in template order and - * deduplicated by name. A name appearing under more than one operator is - * required if *any* of its occurrences is. - */ -export function templateVariables(uriTemplate: string): TemplateVariable[] { - const byName = new Map<string, TemplateVariable>(); - - for (const part of parseUriTemplate(uriTemplate)) { - if (part.kind !== "expression") continue; - const required = !OMITTABLE_OPERATORS.has(part.operator); - for (const name of part.names) { - const existing = byName.get(name); - if (existing) { - existing.required = existing.required || required; - } else { - byName.set(name, { name, operator: part.operator, required }); - } - } - } - - return [...byName.values()]; -} - -/** - * Drops empty entries so an untouched optional field reads as *undefined* to - * the SDK (the expression disappears) rather than as the empty string (which - * would expand to a valueless `?topic=`). - */ -function definedValues(values: Record<string, string>): Record<string, string> { - return Object.fromEntries( - Object.entries(values).filter(([, value]) => value.length > 0), - ); -} - -/** - * Expands a template against the entered values per RFC 6570 -- percent- - * encoding each value according to its operator, and omitting expressions whose - * variables were left blank. - * - * A template the SDK refuses to parse falls back to the raw template string, - * which is what the user already sees in the preview and what the server will - * reject with a legible error; throwing here would take out the whole panel. - */ -export function expandUriTemplate( - uriTemplate: string, - values: Record<string, string>, -): string { - try { - return new UriTemplate(uriTemplate).expand(definedValues(values)); - } catch { - return uriTemplate; - } -} +export { + expandUriTemplate, + parseUriTemplate, + templateVariables, +} from "@inspector/core/mcp/uriTemplate.js"; +export type { + TemplatePart, + TemplateVariable, +} from "@inspector/core/mcp/uriTemplate.js"; /** * A placeholder standing in for an expression the user has not filled in yet. @@ -174,8 +36,8 @@ const deferredToken = (index: number) => `\u0000${index}\u0000`; /** * A partially-expanded template for display: expressions whose variables are - * all filled are expanded exactly as {@link expandUriTemplate} would, and the - * rest are left standing as written so the user can see what is still needed. + * all filled are expanded exactly as `expandUriTemplate` would, and the rest + * are left standing as written so the user can see what is still needed. * * Unfilled expressions are swapped for an inert token and restored after * expansion -- rather than expanding each filled expression in isolation -- so @@ -194,7 +56,10 @@ export function previewUriTemplate( .map((part) => { if (part.kind === "literal") return part.text; if (part.names.every((name) => defined[name] !== undefined)) { - return part.source; + // Route the kept expression through the same multi-name correction the + // real expansion applies, so the preview can never promise a URI that + // submitting would not actually send. + return applyMultiNameCorrection([part], defined); } deferred.push(part.source); return deferredToken(deferred.length - 1); diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a635582d..9cdedd1c 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -191,6 +191,11 @@ import { convertPromptArguments, } from "../json/jsonUtils.js"; import { UriTemplate } from "@modelcontextprotocol/client"; +import { + applyMultiNameCorrection, + definedValues, + parseUriTemplate, +} from "./uriTemplate.js"; import { InspectorClientEventTarget, type TaskWithOptionalCreatedAt, @@ -4971,11 +4976,20 @@ export class InspectorClient extends InspectorClientEventTarget { const uriTemplateString = uriTemplate; - // Expand the template's uriTemplate using the provided params + // Expand the template's uriTemplate using the provided params. Routed + // through the shared helpers in ./uriTemplate.js so this and the web + // Resources form cannot disagree: `definedValues` makes a blank field read + // as *undefined* (the expression drops out) rather than as the empty string + // (a valueless `?topic=`), and `applyMultiNameCorrection` fixes the SDK's + // mis-expansion of `{a,b}`-style expressions before it sees them (#1919). let expandedUri: string; try { - const uriTemplate = new UriTemplate(uriTemplateString); - expandedUri = uriTemplate.expand(params); + const defined = definedValues(params); + const corrected = applyMultiNameCorrection( + parseUriTemplate(uriTemplateString), + defined, + ); + expandedUri = new UriTemplate(corrected).expand(defined); } catch (error) { throw new Error( `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`, diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts new file mode 100644 index 00000000..0518b608 --- /dev/null +++ b/core/mcp/uriTemplate.ts @@ -0,0 +1,257 @@ +/** + * RFC 6570 URI Template parsing and expansion, shared by every client (#1919). + * + * Expansion is delegated to the SDK's `UriTemplate` — with one correction + * applied first, documented on {@link expandMultiNameExpression} below. This + * lives in `core/` rather than in a client so the web form, the TUI, and the + * CLI cannot disagree about what a template means; `InspectorClient + * .readResourceFromTemplate` and the web Resources form both expand through + * {@link expandUriTemplate}. + */ + +import { UriTemplate } from "@modelcontextprotocol/client"; + +/** The RFC 6570 operators, in the order the SDK's parser tests for them. */ +const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const; + +/** + * Operators whose expansion omits cleanly when the variables in it are + * undefined — the whole expression, separator and all, simply disappears and + * what is left is still a well-formed URI naming a real (broader) resource. + * + * The remaining operators — `""` (simple) and `+` (reserved) — interpolate the + * value into the *middle* of the URI, so omitting one leaves an empty path + * segment rather than a shorter URI: measured against the pinned SDK, + * `file:///users/{userId}/profile` expands to `file:///users//profile` and + * `x://a/{+path}` to `x://a/`, both of which name a different resource. Those + * are the ones a form must require before allowing a read. + * + * `#` is in the omittable set on the same measurement: `x://a{#frag}` with no + * `frag` expands to exactly `x://a`. A fragment is optional by construction. + */ +const OMITTABLE_OPERATORS = new Set(["#", ".", "/", "?", "&"]); + +interface TemplateLiteral { + kind: "literal"; + text: string; +} + +interface TemplateExpression { + kind: "expression"; + /** The expression including its braces, e.g. `{?topic}`. */ + source: string; + /** The RFC 6570 operator, or `""` for a simple expression. */ + operator: string; + /** Variable names in the expression, `*` (explode) and whitespace stripped. */ + names: string[]; +} + +export type TemplatePart = TemplateLiteral | TemplateExpression; + +export interface TemplateVariable { + name: string; + /** The operator of the expression the variable was first seen in. */ + operator: string; + /** + * True when omitting the variable would change the URI's structure rather + * than shorten it — see {@link OMITTABLE_OPERATORS}. + */ + required: boolean; +} + +/** + * Splits a template into literal runs and expressions. + * + * Deliberately mirrors the SDK parser's own scanning rules (first matching + * operator character wins, names split on `,`, `*` stripped) so this never + * disagrees with the class that ultimately does the expanding. An unclosed + * `{` yields a trailing literal, which is what makes the callers below degrade + * to "render no inputs, show the template verbatim" rather than throw. + */ +export function parseUriTemplate(uriTemplate: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let literal = ""; + let i = 0; + + while (i < uriTemplate.length) { + if (uriTemplate[i] !== "{") { + literal += uriTemplate[i]; + i += 1; + continue; + } + const end = uriTemplate.indexOf("}", i); + if (end === -1) { + // Unclosed expression — the rest is not a template, treat it as text. + literal += uriTemplate.slice(i); + break; + } + if (literal) { + parts.push({ kind: "literal", text: literal }); + literal = ""; + } + const body = uriTemplate.slice(i + 1, end); + const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; + const names = body + .slice(operator.length) + .split(",") + .map((name) => name.replace("*", "").trim()) + .filter((name) => name.length > 0); + parts.push({ + kind: "expression", + source: uriTemplate.slice(i, end + 1), + operator, + names, + }); + i = end + 1; + } + + if (literal) parts.push({ kind: "literal", text: literal }); + return parts; +} + +/** + * The variables a form should render an input for, in template order and + * deduplicated by name. A name appearing under more than one operator is + * required if *any* of its occurrences is. + */ +export function templateVariables(uriTemplate: string): TemplateVariable[] { + const byName = new Map<string, TemplateVariable>(); + + for (const part of parseUriTemplate(uriTemplate)) { + if (part.kind !== "expression") continue; + const required = !OMITTABLE_OPERATORS.has(part.operator); + for (const name of part.names) { + const existing = byName.get(name); + if (existing) { + existing.required = existing.required || required; + } else { + byName.set(name, { name, operator: part.operator, required }); + } + } + } + + return [...byName.values()]; +} + +/** + * Drops empty entries so an untouched optional field reads as *undefined* to + * the SDK (the expression disappears) rather than as the empty string (which + * would expand to a valueless `?topic=`). + */ +export function definedValues( + values: Record<string, string>, +): Record<string, string> { + return Object.fromEntries( + Object.entries(values).filter(([, value]) => value.length > 0), + ); +} + +/** The SDK's `encodeValue`: reserved characters survive under `+` and `#`. */ +function encodeValue(value: string, operator: string): string { + return operator === "+" || operator === "#" + ? encodeURI(value) + : encodeURIComponent(value); +} + +/** + * Expands a **multi-name, non-query** expression (`{a,b}`, `{/a,b}`, …). + * + * The pinned SDK gets this branch wrong: `UriTemplate.expandPart` takes an + * early `part.names.length > 1` path that raw-joins the values with `,` — + * skipping `encodeValue` *and* the operator prefix entirely. Measured against + * the pinned SDK, `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` + * (unencoded, so the slash creates a path segment — the very defect #1919 is + * about), and `x://a{/p,q}` expands to `x://ax y,z` — no leading `/`, spaces + * intact. Only the `?`/`&` operators are handled correctly there, because they + * are dispatched before that branch. + * + * So those expressions are expanded here and spliced into the template as + * *literal* text before the SDK ever sees them. This is deliberately surgical: + * every other shape — the overwhelmingly common single-name expression, and + * every query expression — still goes through the SDK untouched, so the two + * cannot drift on the ordinary path, and if the SDK fixes its branch this + * correction keeps producing the same (correct) answer. + * + * Splicing is safe because both encoders escape `{` and `}` (to `%7B`/`%7D`), + * so an expanded value can never be re-parsed as an expression. + * + * Returns `""` when no name in the expression has a value, matching RFC 6570's + * rule that an expression with only undefined variables expands to nothing. + */ +function expandMultiNameExpression( + part: TemplateExpression, + values: Record<string, string>, +): string { + const encoded = part.names + .map((name) => values[name]) + .filter((value) => value !== undefined) + .map((value) => encodeValue(value, part.operator)); + + if (encoded.length === 0) return ""; + + switch (part.operator) { + case "#": + return `#${encoded.join(",")}`; + case ".": + return `.${encoded.join(".")}`; + case "/": + return `/${encoded.join("/")}`; + // "" and "+" — a bare comma-joined list, no prefix. + default: + return encoded.join(","); + } +} + +/** + * True for the expressions {@link expandMultiNameExpression} has to take over: + * more than one name, and not a query operator (which the SDK dispatches before + * its broken branch and therefore handles correctly). + */ +function needsMultiNameCorrection(part: TemplateExpression): boolean { + return ( + part.names.length > 1 && part.operator !== "?" && part.operator !== "&" + ); +} + +/** + * Rebuilds `uriTemplate` with every mis-expanded multi-name expression already + * resolved to literal text, leaving the rest for the SDK. + */ +export function applyMultiNameCorrection( + parts: TemplatePart[], + values: Record<string, string>, +): string { + return parts + .map((part) => { + if (part.kind === "literal") return part.text; + return needsMultiNameCorrection(part) + ? expandMultiNameExpression(part, values) + : part.source; + }) + .join(""); +} + +/** + * Expands a template against the entered values per RFC 6570 — percent-encoding + * each value according to its operator, and omitting expressions whose + * variables were left blank. + * + * A template the SDK refuses to parse falls back to the raw template string, + * which is what the user already sees in the preview and what the server will + * reject with a legible error; throwing here would take out the whole panel. + */ +export function expandUriTemplate( + uriTemplate: string, + values: Record<string, string>, +): string { + const defined = definedValues(values); + try { + const corrected = applyMultiNameCorrection( + parseUriTemplate(uriTemplate), + defined, + ); + return new UriTemplate(corrected).expand(defined); + } catch { + return uriTemplate; + } +} diff --git a/test-servers/configs/rfc6570-templates-http.json b/test-servers/configs/rfc6570-templates-http.json index c6949773..61b552c9 100644 --- a/test-servers/configs/rfc6570-templates-http.json +++ b/test-servers/configs/rfc6570-templates-http.json @@ -3,10 +3,23 @@ "name": "rfc6570-template-showcase", "version": "1.0.0" }, - "resourceTemplates": [{ "preset": "rfc6570_templates" }], - "tools": [{ "preset": "echo" }], + "resourceTemplates": [ + { + "preset": "rfc6570_templates" + } + ], + "tools": [ + { + "preset": "echo" + } + ], "transport": { "type": "streamable-http", "port": 6603 - } + }, + "resources": [ + { + "preset": "rfc6570_base" + } + ] } diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 34fd53ad..2def3a1a 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -63,6 +63,7 @@ import { createUserResourceTemplate, createNumberedResourceTemplates, createRfc6570ResourceTemplates, + createRfc6570BaseResource, createSimplePrompt, createArgsPrompt, createNumberedPrompts, @@ -243,6 +244,8 @@ function resolveResourcePreset( return createNumberedResources(Number(get("count")) || 3); case "mcp_app_demo_widget": return createMcpAppDemoResource(); + case "rfc6570_base": + return createRfc6570BaseResource(); default: throw new Error(`Unknown resource preset: ${name}`); } diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 89283e49..9e58cfaa 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1485,6 +1485,27 @@ export function createFileResourceTemplate( * Both handlers echo the URI they were matched against plus the variables the * server decoded, so the round-trip is visible in the read result. */ +/** + * The plain `foobar://events` resource that a blank `{?topic}` read lands on. + * + * Registering it is not decoration. RFC 6570 omits a query expression whose + * variable is undefined, so leaving `topic` blank legitimately requests the + * unfiltered collection -- but the SDK's own matcher cannot serve that from the + * template: `UriTemplate.partToRegExp` compiles `{?topic}` to a **required** + * `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the read + * would 404. A real server would expose the collection as its own resource; + * this fixture does the same so the showcase's "read it blank" step works. + */ +export function createRfc6570BaseResource(): ResourceDefinition { + return { + uri: "foobar://events", + name: "events", + description: "All events - what a blank `{?topic}` read resolves to", + mimeType: "application/json", + text: JSON.stringify({ collection: "events", filtered: false }, null, 2), + }; +} + export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] { const echo = (label: string) => async (uri: URL, params: Record<string, unknown>) => ({ From 25c3ad272e97960336dec630bcbaca6c1767a260 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 19:17:05 -0400 Subject: [PATCH 176/941] fix(core): support ; and :length, and scope requiredness to the expression Addresses Copilot's round-2 review on #2035. It reported "no new comments" but carried three *suppressed* ones; all three reproduced against the pinned SDK. 1. The `;` path-parameter operator is absent from the SDK's operator list, so `{;id}` parsed as a variable literally named ";id" and expanded to nothing. Added the operator and its named expansion (`;a=1;b=2`). 2. An RFC 6570 prefix modifier was folded into the variable name: `{id:3}` yielded a variable called "id:3" and expanded to nothing. Varspecs are now parsed properly and the value truncated before encoding. Truncation is by code point, since `String.prototype.slice` counts UTF-16 units and would split an astral character into a lone surrogate. For both of these the wrong URI is the lesser problem: a form has to *name* the variables it asks the user to fill, so the panel was rendering fields labelled `;id` and `id:3` that nobody could use. 3. Requiredness was applied per variable, but it is a property of the *expression*: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled expands to `a`'s value -- the SDK does this too. The panel was refusing input the expander would have accepted. `hasRequiredValues` now encodes "any one name in a required group suffices", and such a field reads "Any one of: a, b" rather than falsely claiming each is mandatory. Two structural consequences, each pinned by a test: - Takeover is now per TEMPLATE rather than per expression. Splicing corrected fragments into a template the SDK re-expands would leave its cross-expression `?`-to-`&` rewrite blind to the fragments already resolved. - Expansion is split into a strict variant that throws and a lenient one that returns the raw template. `readResourceFromTemplate` wraps the thrown error with the template name -- three pre-existing integration tests assert that -- while the form must not throw on a server-supplied template, since that would take out the panel on render. The strict variant constructs the SDK template unconditionally, because that construction is what validates syntax: otherwise `x://{;a}{b,c` would take the own-expansion path and its unclosed tail would pass as literal text with nothing objecting. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 13 +- README.md | 12 +- .../ResourceTemplatePanel.tsx | 24 +- .../web/src/test/core/mcp/uriTemplate.test.ts | 168 ++++++++- clients/web/src/utils/uriTemplate.test.ts | 10 +- clients/web/src/utils/uriTemplate.ts | 25 +- core/mcp/inspectorClient.ts | 26 +- core/mcp/uriTemplate.ts | 330 +++++++++++++----- 8 files changed, 468 insertions(+), 140 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5005d6d8..527eccbc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,9 +77,16 @@ v2/main/ │ │ # shared by the web Resources form and │ │ # readResourceFromTemplate (TUI + CLI), so the │ │ # clients cannot drift on what a template means; -│ │ # delegates to the SDK's UriTemplate but corrects -│ │ # its multi-name `{a,b}` branch, which skips both -│ │ # encoding and the operator prefix — #1919; +│ │ # delegates to the SDK's UriTemplate for what it +│ │ # gets right, and takes over a whole template +│ │ # containing any of the three shapes it does not: +│ │ # `{a,b}` (raw-joined, unencoded, prefix dropped), +│ │ # `{;id}` (operator absent from its list), and +│ │ # `{id:3}` (prefix modifier folded into the name) — +│ │ # the last two would render form fields literally +│ │ # labelled `;id` / `id:3`. Requiredness is per +│ │ # EXPRESSION, not per variable (hasRequiredValues): +│ │ # `{a,b}` with only `a` filled is expandable — #1919; │ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; diff --git a/README.md b/README.md index 0bc30868..f274f411 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,17 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r > The plain `foobar://events` resource is registered deliberately, not as filler. The SDK's `UriTemplate.match()` compiles `{?topic}` to a **required** `\?topic=([^&]+)`, so a template alone cannot serve the blank read — `match("foobar://events")` returns `null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves. -All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` with one correction applied first: the SDK's `expandPart` takes an early `names.length > 1` branch that raw-joins the values, skipping both percent-encoding **and** the operator prefix, so `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` and `x://a{/p,q}` to `x://ax y,z`. Those expressions are expanded by the helper and spliced in as literal text before the SDK sees them; every single-name and query expression still goes through the SDK untouched. +All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` for every expression the SDK handles correctly, and takes over any template containing one of the three shapes it does not (each measured against the pinned SDK, not inferred): + +| Shape | SDK `variableNames` | SDK expansion | Correct | +| --- | --- | --- | --- | +| `{a,b}` | `["a","b"]` | `foo/bar,q` — unencoded, operator prefix dropped | `foo%2Fbar,q` | +| `{;id}` | `[";id"]` | `""` — the `;` operator is not in its list | `;id=7` | +| `{id:3}` | `["id:3"]` | `""` — the prefix modifier is folded into the name | `abc` | + +The last two matter beyond the URI: a form has to *name* the variables it asks the user to fill, so on the SDK's parse it would render fields literally labelled `;id` and `id:3`. Takeover is per **template**, not per expression, so the cross-expression `?`-to-`&` rewrite always sees every expression that actually emitted. + +One consequence worth knowing when writing a test server: the SDK's **matcher** has the mirrored gaps (`partToRegExp` emits a single capture for `{a,b}` and knows no `;`), so an SDK-backed server cannot round-trip those templates whatever the client sends. Emitting a spec-correct URI is the half the client controls; the unit tests cover those shapes directly rather than through a showcase server. #### Advertised extensions diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index eafd16f4..f2ac2b8e 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -16,6 +16,7 @@ import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge" import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { expandUriTemplate, + hasRequiredValues, previewUriTemplate, templateVariables, } from "../../../utils/uriTemplate"; @@ -215,12 +216,12 @@ export function ResourceTemplatePanel({ void runCompletion(varName, value, buildContext(varName)); } - // Only the variables whose absence would change the URI's shape gate the + // Only the expressions whose absence would change the URI's shape gate the // read; an unfilled `{?topic}` is a legitimate request for the unfiltered - // resource, and RFC 6570 drops the whole expression for it. - const canSubmit = declaredVariables.every( - (v) => !v.required || variables[v.name]?.length > 0, - ); + // resource, and RFC 6570 drops the whole expression for it. The rule is + // per-expression rather than per-variable -- `{a,b}` with only `a` filled + // expands to `a`'s value -- so it lives in core beside the expander. + const canSubmit = hasRequiredValues(declaredVariables, variables); function handleSubmit() { onReadResource(expandUriTemplate(uriTemplate, variables)); @@ -239,12 +240,19 @@ export function ResourceTemplatePanel({ </HeaderRow> {description && <DescriptionText>{description}</DescriptionText>} <Stack gap="sm"> - {declaredVariables.map(({ name: varName, required }) => { + {declaredVariables.map(({ name: varName, required, groupNames }) => { /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ const fieldValue = variables[varName] ?? ""; // RFC 6570 omits an undefined variable under a query/path-segment - // operator entirely, so those fields are genuinely optional. - const description = required ? undefined : "Optional"; + // operator entirely, so those fields are genuinely optional. In a + // required multi-name expression no single field is mandatory either + // -- any one of them satisfies it -- so say which, rather than + // marking each one required and blocking valid input. + const description = !required + ? "Optional" + : groupNames.length > 1 + ? `Any one of: ${groupNames.join(", ")}` + : undefined; return useAutocomplete ? ( <Autocomplete key={varName} diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 570caffc..807786b3 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { expandUriTemplate, + expandUriTemplateStrict, + hasRequiredValues, parseUriTemplate, templateVariables, } from "@inspector/core/mcp/uriTemplate.js"; @@ -13,6 +15,7 @@ describe("parseUriTemplate", () => { kind: "expression", source: "{topic}", operator: "", + varspecs: [{ name: "topic" }], names: ["topic"], }, ]); @@ -25,6 +28,7 @@ describe("parseUriTemplate", () => { kind: "expression", source: "{?a,b*}", operator: "?", + varspecs: [{ name: "a" }, { name: "b" }], names: ["a", "b"], }, ]); @@ -44,13 +48,13 @@ describe("parseUriTemplate", () => { describe("templateVariables", () => { it("finds a simple variable and marks it required", () => { expect(templateVariables("foobar://events/{topic}")).toEqual([ - { name: "topic", operator: "", required: true }, + { name: "topic", operator: "", required: true, groupNames: ["topic"] }, ]); }); it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { expect(templateVariables("foobar://events{?topic}")).toEqual([ - { name: "topic", operator: "?", required: false }, + { name: "topic", operator: "?", required: false, groupNames: ["topic"] }, ]); }); @@ -72,7 +76,7 @@ describe("templateVariables", () => { it("deduplicates a repeated name and keeps it required if any use is", () => { expect(templateVariables("x://{?id}/{id}")).toEqual([ - { name: "id", operator: "?", required: true }, + { name: "id", operator: "?", required: true, groupNames: ["id"] }, ]); }); @@ -192,3 +196,161 @@ describe("expandUriTemplate - multi-name expressions (SDK correction)", () => { ); }); }); + +describe("varspec modifiers", () => { + // The pinned SDK folds a `:length` modifier into the variable name -- + // `new UriTemplate("x://a/{id:3}").variableNames` is `["id:3"]`, and it + // expands to "x://a/" -- so a form built on it would render a field the + // user cannot usefully fill. + it("parses a prefix modifier off the variable name", () => { + expect(templateVariables("x://a/{id:3}")).toEqual([ + { name: "id", operator: "", required: true, groupNames: ["id"] }, + ]); + }); + + it("truncates the value to the prefix length before encoding", () => { + expect(expandUriTemplate("x://a/{id:3}", { id: "abcdef" })).toBe( + "x://a/abc", + ); + }); + + it("encodes what survives truncation", () => { + expect(expandUriTemplate("x://a/{id:3}", { id: "a/bcdef" })).toBe( + "x://a/a%2Fb", + ); + }); + + it("truncates by code point, never splitting an astral character", () => { + // "\u{1F600}" is one code point but two UTF-16 units, so a naive + // `slice(0, 1)` would emit a lone surrogate. + expect(expandUriTemplate("x://{v:1}", { v: "\u{1F600}x" })).toBe( + `x://${encodeURIComponent("\u{1F600}")}`, + ); + }); + + it("ignores a malformed modifier rather than inventing a truncation", () => { + expect(templateVariables("x://{id:}")[0].name).toBe("id"); + expect(expandUriTemplate("x://{id:}", { id: "abcdef" })).toBe("x://abcdef"); + }); + + it("strips the explode modifier from the name", () => { + expect(templateVariables("x://{id*}")[0].name).toBe("id"); + }); +}); + +describe("the ; (path-parameter) operator", () => { + // Absent from the SDK's operator list entirely: it parses `{;id}` as a + // variable named ";id" and expands to "". + it("is recognised as an operator, not part of the name", () => { + expect(templateVariables("x://a{;id}")).toEqual([ + { name: "id", operator: ";", required: false, groupNames: ["id"] }, + ]); + }); + + it("expands to a named path parameter", () => { + expect(expandUriTemplate("x://a{;id}", { id: "7" })).toBe("x://a;id=7"); + }); + + it("repeats its separator per pair", () => { + expect(expandUriTemplate("x://a{;a,b}", { a: "1", b: "2" })).toBe( + "x://a;a=1;b=2", + ); + }); + + it("encodes the value", () => { + expect(expandUriTemplate("x://a{;p}", { p: "x/y" })).toBe("x://a;p=x%2Fy"); + }); + + it("omits cleanly when undefined", () => { + expect(expandUriTemplate("x://a{;id}", {})).toBe("x://a"); + }); +}); + +describe("hasRequiredValues", () => { + // A required *expression* is satisfied by any one of its names, because + // RFC 6570 drops the undefined ones -- verified against the SDK: + // `x://{a,b}` with only `a` expands to "x://only-a". + it("accepts a multi-name expression with only one name filled", () => { + const vars = templateVariables("x://{a,b}"); + expect(hasRequiredValues(vars, { a: "only-a", b: "" })).toBe(true); + expect(expandUriTemplate("x://{a,b}", { a: "only-a", b: "" })).toBe( + "x://only-a", + ); + }); + + it("rejects a multi-name expression with nothing filled", () => { + const vars = templateVariables("x://{a,b}"); + expect(hasRequiredValues(vars, { a: "", b: "" })).toBe(false); + }); + + it("still requires a lone required variable", () => { + const vars = templateVariables("file:///users/{userId}/profile"); + expect(hasRequiredValues(vars, { userId: "" })).toBe(false); + expect(hasRequiredValues(vars, { userId: "alice" })).toBe(true); + }); + + it("never blocks on an omittable expression", () => { + const vars = templateVariables("foobar://events{?topic}"); + expect(hasRequiredValues(vars, { topic: "" })).toBe(true); + }); + + it("is satisfied by a template with no variables at all", () => { + expect(hasRequiredValues(templateVariables("file:///static.txt"), {})).toBe( + true, + ); + }); +}); + +describe("cross-expression query joining", () => { + it("rewrites a second ? to & on the own-expansion path too", () => { + // Forced onto the own-expansion path by the `;` expression; the `?`-to-`&` + // rewrite must still apply, exactly as the SDK does it. + expect( + expandUriTemplate("x://a{;k}{?one}{?two}", { + k: "v", + one: "1", + two: "2", + }), + ).toBe("x://a;k=v?one=1&two=2"); + }); + + it("uses ? for the first query expression that actually emits", () => { + expect( + expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", two: "2" }), + ).toBe("x://a;k=v?two=2"); + }); +}); + +describe("strict vs lenient expansion", () => { + // `readResourceFromTemplate` wraps the thrown error with the template name; + // the web panel instead needs the raw template back, because an invalid + // template comes from the server and throwing would take out the panel. + it.each(["file:///{unclosed", "{a,b,c"])( + "strict throws on the invalid template %s", + (template) => { + expect(() => expandUriTemplateStrict(template, { x: "1" })).toThrow(); + }, + ); + + it.each(["file:///{unclosed", "{a,b,c"])( + "lenient returns %s unchanged", + (template) => { + expect(expandUriTemplate(template, { x: "1" })).toBe(template); + }, + ); + + it("validates syntax even when taking the own-expansion path", () => { + // `{;a}` forces own-expansion, and this module's parser treats the + // unclosed tail as literal text -- so without the unconditional SDK + // construction nothing would reject this. + expect(() => expandUriTemplateStrict("x://{;a}{b,c", { a: "1" })).toThrow(); + }); + + it("agrees with the lenient variant on a valid template", () => { + const template = "foobar://events{?topic}"; + const values = { topic: "foo/bar" }; + expect(expandUriTemplateStrict(template, values)).toBe( + expandUriTemplate(template, values), + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index 7a1ead92..8a50ff7e 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -23,12 +23,18 @@ describe("previewUriTemplate", () => { ).toBe("foobar://events/foo%2Fbar"); }); - it("keeps a multi-name expression whole until every name is filled", () => { + it("expands a partially-filled multi-name expression, as submitting would", () => { + // RFC 6570 drops the undefined names rather than the whole expression, so + // showing `{?one,two}` here would promise a URI the submit does not send. expect(previewUriTemplate("x://a{?one,two}", { one: "1" })).toBe( - "x://a{?one,two}", + "x://a?one=1", ); }); + it("keeps a multi-name expression whole while none of its names is filled", () => { + expect(previewUriTemplate("x://a{?one,two}", {})).toBe("x://a{?one,two}"); + }); + it("still rewrites the second ? to & when both query expressions resolve", () => { expect( previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }), diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 4c3f8ea2..0a14a33d 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -9,20 +9,21 @@ */ import { - applyMultiNameCorrection, definedValues, + expandUriTemplate, parseUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; -import { UriTemplate } from "@modelcontextprotocol/client"; export { expandUriTemplate, + hasRequiredValues, parseUriTemplate, templateVariables, } from "@inspector/core/mcp/uriTemplate.js"; export type { TemplatePart, TemplateVariable, + VarSpec, } from "@inspector/core/mcp/uriTemplate.js"; /** @@ -41,8 +42,10 @@ const deferredToken = (index: number) => `\u0000${index}\u0000`; * * Unfilled expressions are swapped for an inert token and restored after * expansion -- rather than expanding each filled expression in isolation -- so - * the SDK still sees one whole template and applies its cross-expression rules - * (notably rewriting a second `?` query expression to `&`). + * the expander still sees one whole template and applies its cross-expression + * rules (notably rewriting a second `?` query expression to `&`). Routing the + * rewritten template back through `expandUriTemplate` is what keeps the preview + * honest: it can never promise a URI that submitting would not send. */ export function previewUriTemplate( uriTemplate: string, @@ -55,23 +58,15 @@ export function previewUriTemplate( const rewritten = parts .map((part) => { if (part.kind === "literal") return part.text; - if (part.names.every((name) => defined[name] !== undefined)) { - // Route the kept expression through the same multi-name correction the - // real expansion applies, so the preview can never promise a URI that - // submitting would not actually send. - return applyMultiNameCorrection([part], defined); + if (part.names.some((name) => defined[name] !== undefined)) { + return part.source; } deferred.push(part.source); return deferredToken(deferred.length - 1); }) .join(""); - let expanded: string; - try { - expanded = new UriTemplate(rewritten).expand(defined); - } catch { - return uriTemplate; - } + const expanded = expandUriTemplate(rewritten, defined); // Restored by exact-string replacement rather than by a pattern: a regex // matching the token would have to embed U+0000 literally, which `eslint` diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9cdedd1c..2a98dc2f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -190,12 +190,7 @@ import { convertToolParameters, convertPromptArguments, } from "../json/jsonUtils.js"; -import { UriTemplate } from "@modelcontextprotocol/client"; -import { - applyMultiNameCorrection, - definedValues, - parseUriTemplate, -} from "./uriTemplate.js"; +import { expandUriTemplateStrict } from "./uriTemplate.js"; import { InspectorClientEventTarget, type TaskWithOptionalCreatedAt, @@ -4976,20 +4971,15 @@ export class InspectorClient extends InspectorClientEventTarget { const uriTemplateString = uriTemplate; - // Expand the template's uriTemplate using the provided params. Routed - // through the shared helpers in ./uriTemplate.js so this and the web - // Resources form cannot disagree: `definedValues` makes a blank field read - // as *undefined* (the expression drops out) rather than as the empty string - // (a valueless `?topic=`), and `applyMultiNameCorrection` fixes the SDK's - // mis-expansion of `{a,b}`-style expressions before it sees them (#1919). + // Expand through the shared helper in ./uriTemplate.js so this and the web + // Resources form cannot disagree. It drops blank values so an unfilled + // optional field reads as *undefined* (the expression disappears) rather + // than as the empty string (a valueless `?topic=`), and it covers the + // expression shapes the SDK's own expander gets wrong -- `{a,b}`, `{;id}`, + // and the `{id:3}` prefix modifier (#1919). let expandedUri: string; try { - const defined = definedValues(params); - const corrected = applyMultiNameCorrection( - parseUriTemplate(uriTemplateString), - defined, - ); - expandedUri = new UriTemplate(corrected).expand(defined); + expandedUri = expandUriTemplateStrict(uriTemplateString, params); } catch (error) { throw new Error( `Failed to expand URI template "${uriTemplate}": ${error instanceof Error ? error.message : String(error)}`, diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 0518b608..28afd7e0 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -1,18 +1,45 @@ /** * RFC 6570 URI Template parsing and expansion, shared by every client (#1919). * - * Expansion is delegated to the SDK's `UriTemplate` — with one correction - * applied first, documented on {@link expandMultiNameExpression} below. This - * lives in `core/` rather than in a client so the web form, the TUI, and the - * CLI cannot disagree about what a template means; `InspectorClient - * .readResourceFromTemplate` and the web Resources form both expand through - * {@link expandUriTemplate}. + * This lives in `core/` rather than in a client so the web Resources form, the + * TUI, and the CLI cannot disagree about what a template means: the form calls + * {@link templateVariables} / {@link expandUriTemplate} directly, and the TUI + * and CLI reach the same code through `InspectorClient.readResourceFromTemplate`. + * + * ## Why this is not simply `new UriTemplate(t).expand(v)` + * + * Expansion is delegated to the SDK's `UriTemplate` for every expression it + * handles correctly — which is the overwhelmingly common case, and keeping it + * there means we cannot drift from the SDK on the ordinary path. But its parser + * and expander are incomplete in three ways that a *form* makes visible, + * because a form has to name the variables it is asking the user to fill in. + * Each was measured against the pinned SDK, not inferred: + * + * | Shape | SDK `variableNames` | SDK expansion | Correct | + * | ------------ | ------------------- | --------------------------------- | -------------- | + * | `{a,b}` | `["a","b"]` | `foo/bar,q` — unencoded, no prefix| `foo%2Fbar,q` | + * | `{;id}` | `[";id"]` | `""` — operator unknown | `;id=7` | + * | `{id:3}` | `["id:3"]` | `""` — modifier folded into name | `abc` | + * + * For the last two the damage is not just a wrong URI: the form would render + * fields literally labelled `;id` and `id:3`, which the user cannot fill in + * usefully. So this module parses varspecs properly and, **when a template + * contains any expression the SDK gets wrong, expands that whole template + * itself** in {@link expandParts} rather than splicing corrected fragments into + * a template the SDK then re-expands — splicing would leave the SDK's + * cross-expression `?`-to-`&` rewrite unaware of the fragments we resolved. */ import { UriTemplate } from "@modelcontextprotocol/client"; -/** The RFC 6570 operators, in the order the SDK's parser tests for them. */ -const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const; +/** + * The RFC 6570 operators. + * + * `;` is here but **not** in the SDK's own list, which is why `{;id}` parses + * there as a variable literally named `;id`. Order matters only in that each is + * a distinct single character; the first match wins. + */ +const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"] as const; /** * Operators whose expansion omits cleanly when the variables in it are @@ -29,7 +56,20 @@ const OPERATORS = ["+", "#", ".", "/", "?", "&"] as const; * `#` is in the omittable set on the same measurement: `x://a{#frag}` with no * `frag` expands to exactly `x://a`. A fragment is optional by construction. */ -const OMITTABLE_OPERATORS = new Set(["#", ".", "/", "?", "&"]); +const OMITTABLE_OPERATORS = new Set(["#", ".", "/", ";", "?", "&"]); + +/** Operators that expand to `name=value` pairs rather than bare values. */ +const NAMED_OPERATORS = new Set([";", "?", "&"]); + +/** A single variable reference inside an expression, e.g. `id` or `id:3`. */ +export interface VarSpec { + name: string; + /** + * The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*. + * Applied to the value before percent-encoding, per §3.2.1. + */ + maxLength?: number; +} interface TemplateLiteral { kind: "literal"; @@ -42,7 +82,9 @@ interface TemplateExpression { source: string; /** The RFC 6570 operator, or `""` for a simple expression. */ operator: string; - /** Variable names in the expression, `*` (explode) and whitespace stripped. */ + /** The variable references, in order. */ + varspecs: VarSpec[]; + /** Bare variable names, `*` and any `:length` modifier stripped. */ names: string[]; } @@ -53,20 +95,50 @@ export interface TemplateVariable { /** The operator of the expression the variable was first seen in. */ operator: string; /** - * True when omitting the variable would change the URI's structure rather - * than shorten it — see {@link OMITTABLE_OPERATORS}. + * True when the expression this variable belongs to cannot be omitted + * without changing the URI's structure — see {@link OMITTABLE_OPERATORS}. + * + * Note this is a property of the *expression*, not of the single variable: + * RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` + * with only `a` filled expands to `a`'s value. Use {@link hasRequiredValues} + * rather than testing every required variable individually, or a form will + * refuse input the expander would have accepted. */ required: boolean; + /** + * Every name in the expression this variable belongs to, itself included. + * A single-name expression yields a one-element array. + */ + groupNames: string[]; +} + +/** Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. */ +function parseVarSpec(raw: string): VarSpec | null { + // The explode modifier is stripped rather than honored: it only changes how + // a list or map value is joined, and every value reaching this module is a + // single string. + const spec = raw.replace("*", "").trim(); + if (spec.length === 0) return null; + + const colon = spec.indexOf(":"); + if (colon === -1) return { name: spec }; + + const name = spec.slice(0, colon); + const length = Number(spec.slice(colon + 1)); + // A malformed modifier (`{id:}`, `{id:abc}`) is not a valid varspec; keep the + // name and ignore the modifier rather than inventing a truncation. + if (name.length === 0) return null; + return Number.isInteger(length) && length > 0 + ? { name, maxLength: length } + : { name }; } /** * Splits a template into literal runs and expressions. * - * Deliberately mirrors the SDK parser's own scanning rules (first matching - * operator character wins, names split on `,`, `*` stripped) so this never - * disagrees with the class that ultimately does the expanding. An unclosed - * `{` yields a trailing literal, which is what makes the callers below degrade - * to "render no inputs, show the template verbatim" rather than throw. + * An unclosed `{` yields a trailing literal, which is what makes the callers + * below degrade to "render no inputs, show the template verbatim" rather than + * throw at the user. */ export function parseUriTemplate(uriTemplate: string): TemplatePart[] { const parts: TemplatePart[] = []; @@ -91,16 +163,17 @@ export function parseUriTemplate(uriTemplate: string): TemplatePart[] { } const body = uriTemplate.slice(i + 1, end); const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; - const names = body + const varspecs = body .slice(operator.length) .split(",") - .map((name) => name.replace("*", "").trim()) - .filter((name) => name.length > 0); + .map(parseVarSpec) + .filter((spec): spec is VarSpec => spec !== null); parts.push({ kind: "expression", source: uriTemplate.slice(i, end + 1), operator, - names, + varspecs, + names: varspecs.map((spec) => spec.name), }); i = end + 1; } @@ -125,7 +198,12 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { if (existing) { existing.required = existing.required || required; } else { - byName.set(name, { name, operator: part.operator, required }); + byName.set(name, { + name, + operator: part.operator, + required, + groupNames: part.names, + }); } } } @@ -134,9 +212,28 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { } /** - * Drops empty entries so an untouched optional field reads as *undefined* to - * the SDK (the expression disappears) rather than as the empty string (which - * would expand to a valueless `?topic=`). + * Whether `values` supplies everything expansion structurally needs. + * + * A required *expression* is satisfied by any one of its names having a value, + * because RFC 6570 drops the undefined ones — `{a,b}` with only `a` filled + * expands to `a`'s value, which the SDK does too. Testing each required + * variable individually would block that valid input. + */ +export function hasRequiredValues( + variables: TemplateVariable[], + values: Record<string, string>, +): boolean { + return variables.every( + (variable) => + !variable.required || + variable.groupNames.some((name) => (values[name] ?? "").length > 0), + ); +} + +/** + * Drops empty entries so an untouched optional field reads as *undefined* + * (the expression disappears) rather than as the empty string (which would + * expand to a valueless `?topic=`). */ export function definedValues( values: Record<string, string>, @@ -154,103 +251,156 @@ function encodeValue(value: string, operator: string): string { } /** - * Expands a **multi-name, non-query** expression (`{a,b}`, `{/a,b}`, …). + * Applies a prefix modifier, then encodes. * - * The pinned SDK gets this branch wrong: `UriTemplate.expandPart` takes an - * early `part.names.length > 1` path that raw-joins the values with `,` — - * skipping `encodeValue` *and* the operator prefix entirely. Measured against - * the pinned SDK, `x://{a,b}` with `a = "foo/bar"` expands to `x://foo/bar,q` - * (unencoded, so the slash creates a path segment — the very defect #1919 is - * about), and `x://a{/p,q}` expands to `x://ax y,z` — no leading `/`, spaces - * intact. Only the `?`/`&` operators are handled correctly there, because they - * are dispatched before that branch. - * - * So those expressions are expanded here and spliced into the template as - * *literal* text before the SDK ever sees them. This is deliberately surgical: - * every other shape — the overwhelmingly common single-name expression, and - * every query expression — still goes through the SDK untouched, so the two - * cannot drift on the ordinary path, and if the SDK fixes its branch this - * correction keeps producing the same (correct) answer. - * - * Splicing is safe because both encoders escape `{` and `}` (to `%7B`/`%7D`), - * so an expanded value can never be re-parsed as an expression. - * - * Returns `""` when no name in the expression has a value, matching RFC 6570's - * rule that an expression with only undefined variables expands to nothing. + * Truncation is by *code point* (`Array.from`), not by `slice`: RFC 6570 counts + * the prefix in characters, and `String.prototype.slice` counts UTF-16 code + * units, so it can cut an astral character in half and yield a lone surrogate. */ -function expandMultiNameExpression( +function renderValue(value: string, spec: VarSpec, operator: string): string { + const truncated = + spec.maxLength === undefined + ? value + : Array.from(value).slice(0, spec.maxLength).join(""); + return encodeValue(truncated, operator); +} + +/** + * True for an expression the SDK would get wrong, and which this module must + * therefore expand itself. See the table in the module comment. + */ +function needsOwnExpansion(part: TemplatePart): boolean { + if (part.kind !== "expression") return false; + return ( + // Multi-name, non-query: the SDK raw-joins, skipping encoding and prefix. + (part.varspecs.length > 1 && + part.operator !== "?" && + part.operator !== "&") || + // The SDK has no `;` operator at all. + part.operator === ";" || + // The SDK folds everything after a `:` into the variable name. Keyed on the + // raw source rather than on a parsed `maxLength` so a *malformed* modifier + // (`{id:}`, `{id:abc}`) is caught too: this module drops the modifier and + // looks up `id`, while the SDK would look up `id:` and find nothing. + part.source.includes(":") + ); +} + +/** Expands one expression per RFC 6570. Returns "" if no name has a value. */ +function expandExpression( part: TemplateExpression, values: Record<string, string>, ): string { - const encoded = part.names - .map((name) => values[name]) - .filter((value) => value !== undefined) - .map((value) => encodeValue(value, part.operator)); + const present = part.varspecs.filter( + (spec) => values[spec.name] !== undefined, + ); + if (present.length === 0) return ""; - if (encoded.length === 0) return ""; + const { operator } = part; - switch (part.operator) { + if (NAMED_OPERATORS.has(operator)) { + const pairs = present.map( + (spec) => + `${spec.name}=${renderValue(values[spec.name], spec, operator)}`, + ); + // `;` repeats its separator per pair; `?`/`&` join with `&`. + return operator === ";" + ? `;${pairs.join(";")}` + : `${operator}${pairs.join("&")}`; + } + + const rendered = present.map((spec) => + renderValue(values[spec.name], spec, operator), + ); + + switch (operator) { case "#": - return `#${encoded.join(",")}`; + return `#${rendered.join(",")}`; case ".": - return `.${encoded.join(".")}`; + return `.${rendered.join(".")}`; case "/": - return `/${encoded.join("/")}`; + return `/${rendered.join("/")}`; // "" and "+" — a bare comma-joined list, no prefix. default: - return encoded.join(","); + return rendered.join(","); } } /** - * True for the expressions {@link expandMultiNameExpression} has to take over: - * more than one name, and not a query operator (which the SDK dispatches before - * its broken branch and therefore handles correctly). + * Expands a whole parsed template, mirroring the SDK's `expand` — including its + * rule that a second query expression switches its leading `?` to `&`. */ -function needsMultiNameCorrection(part: TemplateExpression): boolean { - return ( - part.names.length > 1 && part.operator !== "?" && part.operator !== "&" - ); -} - -/** - * Rebuilds `uriTemplate` with every mis-expanded multi-name expression already - * resolved to literal text, leaving the rest for the SDK. - */ -export function applyMultiNameCorrection( +export function expandParts( parts: TemplatePart[], values: Record<string, string>, ): string { - return parts - .map((part) => { - if (part.kind === "literal") return part.text; - return needsMultiNameCorrection(part) - ? expandMultiNameExpression(part, values) - : part.source; - }) - .join(""); + let result = ""; + let hasQueryParam = false; + + for (const part of parts) { + if (part.kind === "literal") { + result += part.text; + continue; + } + const expanded = expandExpression(part, values); + if (!expanded) continue; + + const isQuery = part.operator === "?" || part.operator === "&"; + result += isQuery && hasQueryParam ? `&${expanded.slice(1)}` : expanded; + if (isQuery) hasQueryParam = true; + } + + return result; } /** * Expands a template against the entered values per RFC 6570 — percent-encoding * each value according to its operator, and omitting expressions whose - * variables were left blank. + * variables were left blank. **Throws** on a template that is not valid. * - * A template the SDK refuses to parse falls back to the raw template string, - * which is what the user already sees in the preview and what the server will - * reject with a legible error; throwing here would take out the whole panel. + * Templates the SDK handles correctly still go through the SDK, so the two + * cannot drift on the ordinary path; only a template containing at least one + * expression from the table above is expanded here instead. That is a + * whole-template switch rather than a per-expression splice so the `?`-to-`&` + * rewrite always sees every expression that actually produced output. + * + * The SDK template is constructed *before* choosing a path, and unconditionally: + * that construction is what validates the syntax and throws `Unclosed template + * expression`, and callers such as `readResourceFromTemplate` wrap that error. + * Skipping it on the own-expansion path would silently accept a template like + * `{;a}{b,c` — this module's parser treats the unclosed tail as literal text, + * so nothing else would object. + */ +export function expandUriTemplateStrict( + uriTemplate: string, + values: Record<string, string>, +): string { + const defined = definedValues(values); + const sdkTemplate = new UriTemplate(uriTemplate); + const parts = parseUriTemplate(uriTemplate); + return parts.some(needsOwnExpansion) + ? expandParts(parts, defined) + : sdkTemplate.expand(defined); +} + +/** + * {@link expandUriTemplateStrict}, but falling back to the raw template string + * instead of throwing. + * + * This is the form's variant: the template comes from the connected server, not + * from the user, so an invalid one is not something the user can fix from the + * panel — and what they already see in the URI preview is the raw template. + * Letting it throw would take out the whole panel on render; returning it + * unchanged sends the server a URI it rejects with a legible error instead. + * Call sites that need the failure (`readResourceFromTemplate`, which wraps it + * with the template name) use the strict variant. */ export function expandUriTemplate( uriTemplate: string, values: Record<string, string>, ): string { - const defined = definedValues(values); try { - const corrected = applyMultiNameCorrection( - parseUriTemplate(uriTemplate), - defined, - ); - return new UriTemplate(corrected).expand(defined); + return expandUriTemplateStrict(uriTemplate, values); } catch { return uriTemplate; } From e7d1246c86589f76adfa5fc6a3011e6b08d5f72e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 19:35:33 -0400 Subject: [PATCH 177/941] fix(core): scope requiredness to expressions, and encode + / # per RFC 6570 Addresses Copilot's round-3 review on #2035. It again reported "no new comments" while carrying two suppressed ones; both reproduced. 1. Requiredness could not live on a variable at all. Deduplication kept the first occurrence's group, so in `x{?a}{?b}{a,b}` both names ended up required with singleton groups and the form demanded both -- while the SDK expands that template with only `a` to "x?a=11". Widening the stored group would not have been enough either: in `{a,b}{a,c}`, filling `b` and `c` satisfies both expressions, which no per-variable flag can express. So requiredness is now returned per expression by `requiredGroups`, and `hasRequiredValues` asks that each group be satisfied by any one of its names. `TemplateVariable.groupNames` is gone rather than left as a field that quietly means something narrower than it reads; `required` remains, documented as driving the "Optional" marker and nothing else. 2. `encodeURI` is not the allow-reserved encoder `+` and `#` call for. It escapes `[` and `]`, which are reserved and must survive, and it escapes `%`, so an already-encoded value is double-encoded. Measured: "[::1]" -> "%5B::1%5D" and "%2F" -> "%252F". Both corrupt the URI rather than merely over-escaping it -- an IPv6 literal or a pre-encoded path reaches the server altered, which is the same class of defect #1919 is about. Added an RFC 6570 3.2.1 encoder that preserves reserved characters and existing pct-triplets, splitting on `%XX` so a lone `%` is still encoded to `%25`, and matching with the `u` flag so an astral character is encoded whole. `+` and `#` expressions are now taken over even for a single name, so both expansion paths agree on what those operators mean. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 12 +- README.md | 15 ++- .../ResourceTemplatePanel.tsx | 16 ++- .../web/src/test/core/mcp/uriTemplate.test.ts | 110 +++++++++++++++--- clients/web/src/utils/uriTemplate.ts | 1 + core/mcp/uriTemplate.ts | 110 +++++++++++++----- 6 files changed, 204 insertions(+), 60 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 527eccbc..3b5df783 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,11 +82,15 @@ v2/main/ │ │ # containing any of the three shapes it does not: │ │ # `{a,b}` (raw-joined, unencoded, prefix dropped), │ │ # `{;id}` (operator absent from its list), and -│ │ # `{id:3}` (prefix modifier folded into the name) — -│ │ # the last two would render form fields literally +│ │ # `{id:3}` (prefix modifier folded into the name), +│ │ # and `{+v}`/`{#v}` (encodeURI mangles reserved +│ │ # `[`/`]` and double-encodes pct-triplets). The `;` +│ │ # and `:N` cases would render form fields literally │ │ # labelled `;id` / `id:3`. Requiredness is per -│ │ # EXPRESSION, not per variable (hasRequiredValues): -│ │ # `{a,b}` with only `a` filled is expandable — #1919; +│ │ # EXPRESSION, not per variable — requiredGroups + +│ │ # hasRequiredValues: `{a,b}` with only `a` filled is +│ │ # expandable, and a name recurring across +│ │ # expressions needs each group checked — #1919; │ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; diff --git a/README.md b/README.md index f274f411..af8917c5 100644 --- a/README.md +++ b/README.md @@ -251,13 +251,16 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` for every expression the SDK handles correctly, and takes over any template containing one of the three shapes it does not (each measured against the pinned SDK, not inferred): -| Shape | SDK `variableNames` | SDK expansion | Correct | -| --- | --- | --- | --- | -| `{a,b}` | `["a","b"]` | `foo/bar,q` — unencoded, operator prefix dropped | `foo%2Fbar,q` | -| `{;id}` | `[";id"]` | `""` — the `;` operator is not in its list | `;id=7` | -| `{id:3}` | `["id:3"]` | `""` — the prefix modifier is folded into the name | `abc` | +| Shape | SDK behavior | Correct | +| --- | --- | --- | +| `{a,b}` | `foo/bar,q` — raw-joined, unencoded, operator prefix dropped | `foo%2Fbar,q` | +| `{;id}` | `""` — the `;` operator is not in its list, so the variable parses as `;id` | `;id=7` | +| `{id:3}` | `""` — the prefix modifier is folded into the name, giving `id:3` | `abc` | +| `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) | `[::1]`, `%2F` | -The last two matter beyond the URI: a form has to *name* the variables it asks the user to fill, so on the SDK's parse it would render fields literally labelled `;id` and `id:3`. Takeover is per **template**, not per expression, so the cross-expression `?`-to-`&` rewrite always sees every expression that actually emitted. +The `;` and `:3` rows matter beyond the URI: a form has to *name* the variables it asks the user to fill, so on the SDK's parse it would render fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. Takeover is per **template**, not per expression, so the cross-expression `?`-to-`&` rewrite always sees every expression that actually emitted. + +Requiredness is likewise a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and the form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which a per-variable flag cannot express once a name recurs across expressions. One consequence worth knowing when writing a test server: the SDK's **matcher** has the mirrored gaps (`partToRegExp` emits a single capture for `{a,b}` and knows no `;`), so an SDK-backed server cannot round-trip those templates whatever the client sends. Emitting a spec-correct URI is the half the client controls; the unit tests cover those shapes directly rather than through a showcase server. diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index f2ac2b8e..7b39b4ad 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -18,6 +18,7 @@ import { expandUriTemplate, hasRequiredValues, previewUriTemplate, + requiredGroups, templateVariables, } from "../../../utils/uriTemplate"; @@ -96,6 +97,10 @@ export function ResourceTemplatePanel({ () => declaredVariables.map((v) => v.name), [declaredVariables], ); + // The names of each expression that cannot be omitted. Kept separate from + // `declaredVariables` (which is deduplicated for rendering) because each + // required expression has to be satisfied on its own — see `requiredGroups`. + const groups = useMemo(() => requiredGroups(uriTemplate), [uriTemplate]); const [variables, setVariables] = useState<Record<string, string>>(() => Object.fromEntries(variableNames.map((n) => [n, ""])), @@ -221,7 +226,7 @@ export function ResourceTemplatePanel({ // resource, and RFC 6570 drops the whole expression for it. The rule is // per-expression rather than per-variable -- `{a,b}` with only `a` filled // expands to `a`'s value -- so it lives in core beside the expander. - const canSubmit = hasRequiredValues(declaredVariables, variables); + const canSubmit = hasRequiredValues(groups, variables); function handleSubmit() { onReadResource(expandUriTemplate(uriTemplate, variables)); @@ -240,7 +245,7 @@ export function ResourceTemplatePanel({ </HeaderRow> {description && <DescriptionText>{description}</DescriptionText>} <Stack gap="sm"> - {declaredVariables.map(({ name: varName, required, groupNames }) => { + {declaredVariables.map(({ name: varName, required }) => { /* v8 ignore next -- `?? ""` fallback unreachable: `variables` is seeded with every declared variable, so the key is always present. */ const fieldValue = variables[varName] ?? ""; // RFC 6570 omits an undefined variable under a query/path-segment @@ -248,10 +253,13 @@ export function ResourceTemplatePanel({ // required multi-name expression no single field is mandatory either // -- any one of them satisfies it -- so say which, rather than // marking each one required and blocking valid input. + const sharedGroup = groups.find( + (names) => names.length > 1 && names.includes(varName), + ); const description = !required ? "Optional" - : groupNames.length > 1 - ? `Any one of: ${groupNames.join(", ")}` + : sharedGroup + ? `Any one of: ${sharedGroup.join(", ")}` : undefined; return useAutocomplete ? ( <Autocomplete diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 807786b3..4c84d807 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -4,6 +4,7 @@ import { expandUriTemplateStrict, hasRequiredValues, parseUriTemplate, + requiredGroups, templateVariables, } from "@inspector/core/mcp/uriTemplate.js"; @@ -48,13 +49,13 @@ describe("parseUriTemplate", () => { describe("templateVariables", () => { it("finds a simple variable and marks it required", () => { expect(templateVariables("foobar://events/{topic}")).toEqual([ - { name: "topic", operator: "", required: true, groupNames: ["topic"] }, + { name: "topic", operator: "", required: true }, ]); }); it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { expect(templateVariables("foobar://events{?topic}")).toEqual([ - { name: "topic", operator: "?", required: false, groupNames: ["topic"] }, + { name: "topic", operator: "?", required: false }, ]); }); @@ -76,7 +77,7 @@ describe("templateVariables", () => { it("deduplicates a repeated name and keeps it required if any use is", () => { expect(templateVariables("x://{?id}/{id}")).toEqual([ - { name: "id", operator: "?", required: true, groupNames: ["id"] }, + { name: "id", operator: "?", required: true }, ]); }); @@ -204,7 +205,7 @@ describe("varspec modifiers", () => { // user cannot usefully fill. it("parses a prefix modifier off the variable name", () => { expect(templateVariables("x://a/{id:3}")).toEqual([ - { name: "id", operator: "", required: true, groupNames: ["id"] }, + { name: "id", operator: "", required: true }, ]); }); @@ -243,7 +244,7 @@ describe("the ; (path-parameter) operator", () => { // variable named ";id" and expands to "". it("is recognised as an operator, not part of the name", () => { expect(templateVariables("x://a{;id}")).toEqual([ - { name: "id", operator: ";", required: false, groupNames: ["id"] }, + { name: "id", operator: ";", required: false }, ]); }); @@ -266,39 +267,67 @@ describe("the ; (path-parameter) operator", () => { }); }); -describe("hasRequiredValues", () => { +describe("requiredGroups / hasRequiredValues", () => { // A required *expression* is satisfied by any one of its names, because // RFC 6570 drops the undefined ones -- verified against the SDK: // `x://{a,b}` with only `a` expands to "x://only-a". it("accepts a multi-name expression with only one name filled", () => { - const vars = templateVariables("x://{a,b}"); - expect(hasRequiredValues(vars, { a: "only-a", b: "" })).toBe(true); + const groups = requiredGroups("x://{a,b}"); + expect(groups).toEqual([["a", "b"]]); + expect(hasRequiredValues(groups, { a: "only-a", b: "" })).toBe(true); expect(expandUriTemplate("x://{a,b}", { a: "only-a", b: "" })).toBe( "x://only-a", ); }); it("rejects a multi-name expression with nothing filled", () => { - const vars = templateVariables("x://{a,b}"); - expect(hasRequiredValues(vars, { a: "", b: "" })).toBe(false); + expect( + hasRequiredValues(requiredGroups("x://{a,b}"), { a: "", b: "" }), + ).toBe(false); }); it("still requires a lone required variable", () => { - const vars = templateVariables("file:///users/{userId}/profile"); - expect(hasRequiredValues(vars, { userId: "" })).toBe(false); - expect(hasRequiredValues(vars, { userId: "alice" })).toBe(true); + const groups = requiredGroups("file:///users/{userId}/profile"); + expect(hasRequiredValues(groups, { userId: "" })).toBe(false); + expect(hasRequiredValues(groups, { userId: "alice" })).toBe(true); }); it("never blocks on an omittable expression", () => { - const vars = templateVariables("foobar://events{?topic}"); - expect(hasRequiredValues(vars, { topic: "" })).toBe(true); + expect(requiredGroups("foobar://events{?topic}")).toEqual([]); + expect( + hasRequiredValues(requiredGroups("foobar://events{?topic}"), { + topic: "", + }), + ).toBe(true); }); it("is satisfied by a template with no variables at all", () => { - expect(hasRequiredValues(templateVariables("file:///static.txt"), {})).toBe( + expect(hasRequiredValues(requiredGroups("file:///static.txt"), {})).toBe( true, ); }); + + it("tracks a name that recurs under a different operator", () => { + // A per-variable model keeping only the first occurrence's group would + // mark both names required with singleton groups and refuse this input; + // the SDK expands the same template with just `a` to "x?a=11". + const groups = requiredGroups("x{?a}{?b}{a,b}"); + expect(groups).toEqual([["a", "b"]]); + expect(hasRequiredValues(groups, { a: "1", b: "" })).toBe(true); + expect(expandUriTemplate("x{?a}{?b}{a,b}", { a: "1" })).toBe("x?a=11"); + }); + + it("satisfies two required expressions sharing a name, from the others", () => { + // `{a,b}{a,c}`: filling only b and c satisfies both groups. No + // per-variable flag can express this, which is why groups are separate. + const groups = requiredGroups("x://{a,b}{a,c}"); + expect(groups).toEqual([ + ["a", "b"], + ["a", "c"], + ]); + expect(hasRequiredValues(groups, { b: "B", c: "C" })).toBe(true); + expect(hasRequiredValues(groups, { b: "B" })).toBe(false); + }); }); describe("cross-expression query joining", () => { @@ -354,3 +383,52 @@ describe("strict vs lenient expansion", () => { ); }); }); + +describe("allow-reserved encoding under + and #", () => { + // The SDK uses `encodeURI` for these operators, which corrupts two classes + // of value rather than merely over-escaping: measured, encodeURI("[::1]") + // is "%5B::1%5D" and encodeURI("%2F") is "%252F". + it.each(["+", "#"])("leaves reserved [ and ] intact under %s", (operator) => { + const prefix = operator === "#" ? "#" : ""; + expect(expandUriTemplate(`x://{${operator}v}`, { v: "[::1]" })).toBe( + `x://${prefix}[::1]`, + ); + }); + + it.each(["+", "#"])( + "does not double-encode an existing pct-triplet under %s", + (operator) => { + const prefix = operator === "#" ? "#" : ""; + expect(expandUriTemplate(`x://{${operator}v}`, { v: "a%2Fb" })).toBe( + `x://${prefix}a%2Fb`, + ); + }, + ); + + it("still encodes a lone % that is not a triplet", () => { + expect(expandUriTemplate("x://{+v}", { v: "100%" })).toBe("x://100%25"); + }); + + it("still encodes characters outside the allowed set", () => { + expect(expandUriTemplate("x://{+v}", { v: "a b" })).toBe("x://a%20b"); + }); + + it("encodes an astral character whole rather than as surrogates", () => { + expect(expandUriTemplate("x://{+v}", { v: "\u{1F600}" })).toBe( + `x://${encodeURIComponent("\u{1F600}")}`, + ); + }); + + it("applies the same encoding in a multi-name + expression", () => { + expect(expandUriTemplate("x://{+a,b}", { a: "[::1]", b: "%2F" })).toBe( + "x://[::1],%2F", + ); + }); + + it("still percent-encodes reserved characters under the simple operator", () => { + // Only + and # allow reserved through; the default path is unchanged. + expect(expandUriTemplate("x://{v}", { v: "[::1]" })).toBe( + "x://%5B%3A%3A1%5D", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 0a14a33d..56b2b825 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -18,6 +18,7 @@ export { expandUriTemplate, hasRequiredValues, parseUriTemplate, + requiredGroups, templateVariables, } from "@inspector/core/mcp/uriTemplate.js"; export type { diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 28afd7e0..0a790cf0 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -95,21 +95,17 @@ export interface TemplateVariable { /** The operator of the expression the variable was first seen in. */ operator: string; /** - * True when the expression this variable belongs to cannot be omitted - * without changing the URI's structure — see {@link OMITTABLE_OPERATORS}. + * True when this variable appears in at least one expression that cannot be + * omitted without changing the URI's structure — see + * {@link OMITTABLE_OPERATORS}. Drives the form's "Optional" marker. * - * Note this is a property of the *expression*, not of the single variable: - * RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` - * with only `a` filled expands to `a`'s value. Use {@link hasRequiredValues} - * rather than testing every required variable individually, or a form will - * refuse input the expander would have accepted. + * It does **not** mean "the user must fill this field in". Requiredness is a + * property of the *expression*: RFC 6570 drops undefined names from a + * multi-name expression, so `{a,b}` with only `a` filled expands to `a`'s + * value. Gate submission on {@link hasRequiredValues} over + * {@link requiredGroups}, never by testing this flag per variable. */ required: boolean; - /** - * Every name in the expression this variable belongs to, itself included. - * A single-name expression yields a one-element array. - */ - groupNames: string[]; } /** Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. */ @@ -198,12 +194,7 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { if (existing) { existing.required = existing.required || required; } else { - byName.set(name, { - name, - operator: part.operator, - required, - groupNames: part.names, - }); + byName.set(name, { name, operator: part.operator, required }); } } } @@ -212,21 +203,39 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { } /** - * Whether `values` supplies everything expansion structurally needs. + * The variable names of each expression that cannot be omitted, in template + * order — one entry per expression, not per variable. * - * A required *expression* is satisfied by any one of its names having a value, - * because RFC 6570 drops the undefined ones — `{a,b}` with only `a` filled - * expands to `a`'s value, which the SDK does too. Testing each required - * variable individually would block that valid input. + * This is deliberately *not* folded onto {@link TemplateVariable}. A name can + * appear in several expressions with different operators, and each required + * expression has to be satisfied on its own: in `x{?a}{?b}{a,b}` the only + * required expression is `{a,b}`, which either `a` or `b` satisfies (the SDK + * expands that template with just `a` to `x?a=11`), while a per-variable model + * that kept only the first occurrence's group would mark both names required + * with singleton groups and refuse it. And in `{a,b}{a,c}` — two required + * expressions sharing `a` — filling `b` and `c` satisfies both, which no + * per-variable flag can express at all. + */ +export function requiredGroups(uriTemplate: string): string[][] { + const groups: string[][] = []; + for (const part of parseUriTemplate(uriTemplate)) { + if (part.kind !== "expression") continue; + if (OMITTABLE_OPERATORS.has(part.operator)) continue; + groups.push(part.names); + } + return groups; +} + +/** + * Whether `values` supplies everything expansion structurally needs: every + * required expression has at least one of its names filled in. */ export function hasRequiredValues( - variables: TemplateVariable[], + groups: string[][], values: Record<string, string>, ): boolean { - return variables.every( - (variable) => - !variable.required || - variable.groupNames.some((name) => (values[name] ?? "").length > 0), + return groups.every((names) => + names.some((name) => (values[name] ?? "").length > 0), ); } @@ -243,10 +252,45 @@ export function definedValues( ); } -/** The SDK's `encodeValue`: reserved characters survive under `+` and `#`. */ +/** + * The characters RFC 6570 leaves alone under the `+` and `#` operators: + * RFC 3986 *unreserved* plus *reserved* (gen-delims and sub-delims). + */ +const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu; + +/** + * The `allow-reserved` value encoding of RFC 6570 §3.2.1, used by `+` and `#`. + * + * The SDK reaches for `encodeURI` here, which is close but wrong twice over, + * and both cases corrupt the URI rather than merely over-escaping it: + * + * - It escapes `[` and `]`, which are *reserved* and must survive — so an IPv6 + * literal `[::1]` becomes `%5B::1%5D`. + * - It escapes `%`, so an already-encoded value is double-encoded: `%2F` + * becomes `%252F`, and the server sees a literal "%2F" rather than a slash. + * + * The spec instead keeps existing pct-triplets intact, which is what the split + * below does: odd chunks are whole `%XX` triplets and pass through untouched, + * even chunks are scanned for anything outside the allowed set. A lone `%` is + * not a triplet, so it lands in an even chunk and is correctly encoded to + * `%25`. The `u` flag makes the class match by code point, so an astral + * character is handed to `encodeURIComponent` whole rather than as surrogates. + */ +function encodeAllowReserved(value: string): string { + return value + .split(/(%[0-9A-Fa-f]{2})/g) + .map((chunk, index) => + index % 2 === 1 + ? chunk + : chunk.replace(ALLOW_RESERVED, (char) => encodeURIComponent(char)), + ) + .join(""); +} + +/** Encodes one value for its operator: reserved characters survive `+` and `#`. */ function encodeValue(value: string, operator: string): string { return operator === "+" || operator === "#" - ? encodeURI(value) + ? encodeAllowReserved(value) : encodeURIComponent(value); } @@ -278,6 +322,12 @@ function needsOwnExpansion(part: TemplatePart): boolean { part.operator !== "&") || // The SDK has no `;` operator at all. part.operator === ";" || + // The SDK encodes `+`/`#` values with `encodeURI`, which mangles reserved + // `[`/`]` and double-encodes existing pct-triplets — see + // `encodeAllowReserved`. Taken over even for a single name so both paths + // agree on what these operators mean. + part.operator === "+" || + part.operator === "#" || // The SDK folds everything after a `:` into the variable name. Keyed on the // raw source rather than on a parsed `maxLength` so a *malformed* modifier // (`{id:}`, `{id:abc}`) is caught too: this module drops the modifier and From 07a7b0beb5a1875e90e607bba53a9c5837bebb5e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 20:07:16 -0400 Subject: [PATCH 178/941] fix(web): accept CRLF-delimited SSE frames on the servers events stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/api/servers/events` reader in `useServers` searched for `\n\n` and consumed exactly two characters as the separator. SSE permits CRLF line endings, so a `event: change\r\n\r\n` frame stayed buffered forever: `isSseDataFrame()` was never reached and no background refresh fired, leaving the server list stale until a manual refresh or an LF-delimited event arrived. Frame boundaries are now located with a CRLF-aware matcher that reports the separator's real length, and frame lines split on either terminator. A bare CR is deliberately not accepted as a terminator: it cannot be distinguished from the first half of a CRLF straddling a chunk boundary, so a buffer ending in `…\r\n\r` would read as a completed frame and the `\n` arriving next would open a bogus one. Two regressions cover it, both verified failing against the old parser. Each uses a referentially stable `fetchFn` and holds the frame behind a promise released after the disk write — with an inline `fetchFn` the hook re-subscribes every render and that re-subscription's own mount refresh picks the edit up regardless, so the test would pass unfixed. Closes #2006 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../src/test/core/react/useServers.test.tsx | 146 ++++++++++++++++++ core/react/useServers.ts | 44 +++++- 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index bb95402d..e7c2fbf3 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -969,6 +969,152 @@ describe("useServers", () => { ); }); + it("parses CRLF-delimited SSE frames as change notifications (#2006)", async () => { + // SSE permits CRLF line endings, and this hook is a hand-rolled parser + // (EventSource can't send `x-mcp-remote-auth`). Matching only `\n\n` left + // a CRLF frame buffered forever, so an external mcp.json edit never + // reached the list. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + // The frame is held back until the test has mutated the file, so the + // resulting list can only come from a refresh this frame triggered. + let releaseFrame: (() => void) | undefined; + const framed = new Promise<void>((r) => { + releaseFrame = r; + }); + let reads = 0; + const encoder = new TextEncoder(); + // Referentially stable: an inline arrow re-subscribes on every render, and + // the re-subscription's own mount refresh would pick the edit up on its + // own — passing whether or not the frame was ever parsed. + const fetchFn: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + if (reads === 1) { + await framed; + return { + done: false, + value: encoder.encode("event: change\r\n\r\n"), + }; + } + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + return h.fetchFn(url, init); + }; + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.servers.map((s) => s.id)).toEqual(["seed"]); + + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { aftercrlf: { type: "stdio", command: "x" } }, + }), + ); + releaseFrame?.(); + + await waitFor( + () => { + expect(result.current.servers.map((s) => s.id)).toEqual(["aftercrlf"]); + }, + { timeout: 3000 }, + ); + }); + + it("does not split a CRLF frame separator straddling two chunks (#2006)", async () => { + // The bare-CR terminator is deliberately not accepted: a chunk ending in + // `…\r\n\r` would otherwise read as a completed frame, and the `\n` + // arriving next would start a bogus one. Deliver exactly that split and + // assert the frame is recognized once — only after both halves land. + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { seed: { type: "stdio", command: "s" } }, + }), + ); + + let releaseFirstHalf: (() => void) | undefined; + const firstHalf = new Promise<void>((r) => { + releaseFirstHalf = r; + }); + let releaseSecondHalf: (() => void) | undefined; + const secondHalf = new Promise<void>((r) => { + releaseSecondHalf = r; + }); + let listGets = 0; + let reads = 0; + const encoder = new TextEncoder(); + const fetchFn: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.endsWith("/api/servers/events")) { + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + if (reads === 1) { + await firstHalf; + return { + done: false, + value: encoder.encode("event: change\r\n\r"), + }; + } + if (reads === 2) { + await secondHalf; + return { done: false, value: encoder.encode("\n") }; + } + return { done: true, value: undefined }; + }, + }), + }; + return { ok: true, body } as unknown as Response; + } + if (url.endsWith("/api/servers")) listGets += 1; + return h.fetchFn(url, init); + }; + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listGets).toBe(1); + + // Half a separator is not a frame: no refresh yet. + releaseFirstHalf?.(); + await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); + await new Promise((r) => setTimeout(r, 100)); + expect(listGets).toBe(1); + + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { aftersplit: { type: "stdio", command: "x" } }, + }), + ); + releaseSecondHalf?.(); + + await waitFor( + () => { + expect(result.current.servers.map((s) => s.id)).toEqual(["aftersplit"]); + }, + { timeout: 3000 }, + ); + expect(listGets).toBe(2); + }); + it("ignores the backend's inert priming comment frame (#1858)", async () => { // The backend opens every SSE stream with a `:` comment so a streaming // fetch() resolves on Firefox at all. That frame carries no event/data diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 3a2a5002..c9713344 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -90,10 +90,38 @@ async function readErrorMessage(res: Response): Promise<string> { */ function isSseDataFrame(frame: string): boolean { return frame - .split("\n") + .split(SSE_LINE_SEPARATOR) .some((line) => line.startsWith("event:") || line.startsWith("data:")); } +/** + * SSE line terminators. The spec permits CRLF, LF, and a bare CR; we accept + * the first two. A bare CR is deliberately excluded because it can't be told + * apart from the first half of a CRLF that straddles a chunk boundary — a + * buffer ending in `…\r\n\r` would look like a completed frame and the `\n` + * arriving in the next chunk would be mis-read as the start of another. No + * SSE producer in the wild emits bare-CR frames, so the ambiguity buys + * nothing. + * + * A frame ends at a blank line, i.e. two terminators back to back. + */ +const SSE_LINE_SEPARATOR = /\r\n|\n/; +const SSE_FRAME_SEPARATOR = /(?:\r\n|\n){2}/; + +/** + * Locates the next frame boundary in `buffer`. Returns the frame's end offset + * and the length of the blank-line separator that terminated it (2–4 chars, + * depending on which terminators the server used), or `null` when the buffer + * holds no complete frame yet. + */ +function findSseFrameEnd( + buffer: string, +): { end: number; separatorLength: number } | null { + const match = SSE_FRAME_SEPARATOR.exec(buffer); + if (!match) return null; + return { end: match.index, separatorLength: match[0].length }; +} + export function useServers(opts: UseServersOptions): UseServersResult { const { baseUrl, authToken, fetchFn } = opts; const doFetch = fetchFn ?? globalThis.fetch; @@ -163,7 +191,9 @@ export function useServers(opts: UseServersOptions): UseServersResult { const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - // SSE frames are separated by a blank line (`\n\n`). We don't parse + // SSE frames are separated by a blank line — LF- or CRLF-terminated, + // both of which the spec permits and `findSseFrameEnd` accepts (#2006). + // We don't parse // the event type or data — `refreshInternal()` re-fetches the // canonical state regardless — so we just count frames and fire a // single background refresh per decode chunk. Two `change` @@ -183,12 +213,12 @@ export function useServers(opts: UseServersOptions): UseServersResult { if (done) break; buffer += decoder.decode(value, { stream: true }); let sawFrame = false; - let frameEnd = buffer.indexOf("\n\n"); - while (frameEnd !== -1) { - const frame = buffer.slice(0, frameEnd); - buffer = buffer.slice(frameEnd + 2); + let boundary = findSseFrameEnd(buffer); + while (boundary !== null) { + const frame = buffer.slice(0, boundary.end); + buffer = buffer.slice(boundary.end + boundary.separatorLength); if (isSseDataFrame(frame)) sawFrame = true; - frameEnd = buffer.indexOf("\n\n"); + boundary = findSseFrameEnd(buffer); } if (sawFrame) void refreshInternal(true); } From 364d8c9dbc01de49b2c7d28366332e00dd514d23 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 20:23:25 -0400 Subject: [PATCH 179/941] feat(auth): per-server authorization and token URL overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional per-server fields — `oauth.authorizationUrl` and `oauth.tokenUrl` — that override the endpoints authorization-server metadata discovery resolved, so a server can be pointed at a development or staging authorization server without changing what it advertises. SDK v2 routes both endpoints through the discovered metadata document and neither through OAuthClientProvider, so the override is applied by wrapping the client's base fetch and patching the metadata document in flight (core/auth/endpointOverrides.ts). Wrapping the base fetch — rather than only the auth fetch — also covers the discovery the SDK runs from inside the transport on the 401/refresh path. The fields round-trip through mcp.json, the /api/servers routes, the runner (CLI/TUI), and the Server Settings → Authorization panel, where a value that is not an absolute http(s) URL is flagged inline; at runtime such a value is dropped with a single warning rather than failing the connection. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 8 +- README.md | 2 +- clients/web/src/App.tsx | 6 + .../ServerSettingsForm.test.tsx | 76 +++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 52 ++++ .../ServerSettingsModal.test.tsx | 46 +++ .../ServerSettingsModal.tsx | 5 + .../test/core/auth/endpointOverrides.test.ts | 276 ++++++++++++++++++ .../web/src/test/core/client/runner.test.ts | 40 +++ ...torClient-oauth-endpoint-overrides.test.ts | 143 +++++++++ .../src/test/core/mcp/oauthManager.test.ts | 24 ++ .../web/src/test/core/mcp/serverList.test.ts | 61 ++++ .../mcp/remote/servers-route.test.ts | 69 +++++ core/auth/endpointOverrides.ts | 246 ++++++++++++++++ core/auth/index.ts | 10 + core/client/runner.ts | 10 +- core/mcp/inspectorClient.ts | 14 + core/mcp/oauthManager.ts | 18 ++ core/mcp/remote/node/server.ts | 31 +- core/mcp/serverList.ts | 36 +++ core/mcp/types.ts | 39 +++ docs/mcp-server-configuration.md | 6 +- 22 files changed, 1212 insertions(+), 6 deletions(-) create mode 100644 clients/web/src/test/core/auth/endpointOverrides.test.ts create mode 100644 clients/web/src/test/core/mcp/inspectorClient-oauth-endpoint-overrides.test.ts create mode 100644 core/auth/endpointOverrides.ts diff --git a/AGENTS.md b/AGENTS.md index d07e84f4..3deca03b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,13 @@ v2/main/ │ │ # providers.redirectToAuthorization, the only │ │ # seam that sees the SDK-built authorize URL; │ │ # authorization request only, never the token -│ │ # request — #2018) +│ │ # request — #2018; +│ │ # endpointOverrides.ts per-server +│ │ # authorization/token URL overrides — a fetch +│ │ # wrapper that rewrites the discovered AS +│ │ # metadata document, the one seam SDK v2 routes +│ │ # BOTH endpoints through (neither reaches the +│ │ # OAuthClientProvider) — #1906) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) │ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, │ │ │ # runner-interactive-oauth loopback callback flow) diff --git a/README.md b/README.md index cb57dd35..a9fab0c1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) -│ ├── auth/ # OAuth: providers, discovery, storage, mid-session recovery (browser/node/remote backends) +│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends) │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets │ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union │ │ # schema collapse shared by the web and TUI form builders diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 72d8bd1f..5837f27f 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -64,6 +64,7 @@ import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSu import { cleanRoots, oauthAuthorizationParamsFromSettings, + oauthEndpointOverridesFromSettings, serializeMcpConfig, } from "@inspector/core/mcp/serverList.js"; import type { ClientConfig } from "@inspector/core/client/types.js"; @@ -2315,12 +2316,16 @@ function App() { const serverAuthorizationParams = savedSettings ? oauthAuthorizationParamsFromSettings(savedSettings) : undefined; + const serverEndpointOverrides = savedSettings + ? oauthEndpointOverridesFromSettings(savedSettings) + : undefined; const oauthFromServer = savedSettings && (savedSettings.oauthClientId || savedSettings.oauthClientSecret || savedSettings.oauthScopes || serverAuthorizationParams || + serverEndpointOverrides || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -2335,6 +2340,7 @@ function App() { ...(serverAuthorizationParams && { authorizationParams: serverAuthorizationParams, }), + ...serverEndpointOverrides, ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index d238228a..ac26d2eb 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -701,6 +701,8 @@ describe("ServerSettingsForm", () => { clientSecret: "", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: false, }); }); @@ -966,6 +968,76 @@ describe("ServerSettingsForm", () => { expect(screen.getByText(/state, scope are set/)).toBeInTheDocument(); }); + // #1906 — the endpoint overrides ride the same `onOAuthChange` callback as + // the rest of the Authorization section. + it("invokes onOAuthChange when an endpoint override is typed", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + onOAuthChange={onOAuthChange} + settings={emptySettings} + expandedSections={["oauth"]} + />, + ); + await user.type( + screen.getByRole("textbox", { name: /Authorization URL override/i }), + "h", + ); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ authorizationUrl: "h" }), + ); + + onOAuthChange.mockClear(); + await user.type( + screen.getByRole("textbox", { name: /Token URL override/i }), + "h", + ); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ tokenUrl: "h" }), + ); + }); + + it("flags an endpoint override that is not an absolute http(s) URL", () => { + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ + ...emptySettings, + oauthAuthorizationUrl: "/authorize", + oauthTokenUrl: "https://staging.test/token", + }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getByText('"/authorize" is not an absolute URL.'), + ).toBeInTheDocument(); + expect(screen.queryByText(/is not an http\(s\) URL/)).toBeNull(); + }); + + it("clears an endpoint override through its clear button", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + onOAuthChange={onOAuthChange} + settings={{ + ...emptySettings, + oauthTokenUrl: "https://staging.test/token", + }} + expandedSections={["oauth"]} + />, + ); + const clearButtons = screen.getAllByRole("button", { name: /clear/i }); + await user.click(clearButtons[clearButtons.length - 1]); + expect(onOAuthChange).toHaveBeenCalledWith( + expect.objectContaining({ tokenUrl: "" }), + ); + }); + it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => { const user = userEvent.setup(); const onOAuthChange = vi.fn(); @@ -1007,6 +1079,8 @@ describe("ServerSettingsForm", () => { clientSecret: "", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: true, }); }); @@ -1112,6 +1186,8 @@ describe("ServerSettingsForm", () => { clientSecret: "z", scopes: "", authorizationParams: [], + authorizationUrl: "", + tokenUrl: "", enterpriseManaged: false, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 276c9cf9..887555c3 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -32,6 +32,7 @@ import { authorizationParamKeyError, isReservedAuthorizationParam, } from "@inspector/core/auth/authorizationParams.js"; +import { oauthEndpointUrlError } from "@inspector/core/auth/endpointOverrides.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; import type { Root } from "@modelcontextprotocol/client"; @@ -478,6 +479,8 @@ export function ServerSettingsForm({ clientSecret: settings.oauthClientSecret ?? "", scopes: settings.oauthScopes ?? "", authorizationParams, + authorizationUrl: settings.oauthAuthorizationUrl ?? "", + tokenUrl: settings.oauthTokenUrl ?? "", enterpriseManaged: settings.enterpriseManaged ?? false, onInsufficientScope: settings.oauthOnInsufficientScope, }; @@ -863,6 +866,55 @@ export function ServerSettingsForm({ + Add Parameter </AddButton> </Stack> + <ClearableTextInput + label="Authorization URL override" + description="Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead." + placeholder="https://staging.auth.example.com/authorize" + value={settings.oauthAuthorizationUrl ?? ""} + error={oauthEndpointUrlError( + settings.oauthAuthorizationUrl ?? "", + )} + onChange={(e) => + onOAuthChange({ + ...currentOAuth(), + authorizationUrl: e.currentTarget.value, + }) + } + rightSection={ + settings.oauthAuthorizationUrl ? ( + <ClearButton + onClick={() => + onOAuthChange({ + ...currentOAuth(), + authorizationUrl: "", + }) + } + /> + ) : null + } + /> + <ClearableTextInput + label="Token URL override" + description="Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone." + placeholder="https://staging.auth.example.com/token" + value={settings.oauthTokenUrl ?? ""} + error={oauthEndpointUrlError(settings.oauthTokenUrl ?? "")} + onChange={(e) => + onOAuthChange({ + ...currentOAuth(), + tokenUrl: e.currentTarget.value, + }) + } + rightSection={ + settings.oauthTokenUrl ? ( + <ClearButton + onClick={() => + onOAuthChange({ ...currentOAuth(), tokenUrl: "" }) + } + /> + ) : null + } + /> <Select label="Insufficient-scope response" description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you." diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index a6faac5e..1e14d641 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -445,6 +445,52 @@ describe("ServerSettingsModal", () => { }); }); + // #1906 — a typed endpoint override lands on the settings; clearing it + // persists `undefined` rather than an empty string, so "cleared" and "never + // set" are the same state on disk. + it("persists a typed endpoint override, and clears it back to undefined", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + const { rerender } = renderWithMantine( + <ServerSettingsModal + opened + settings={emptySettings} + serverType="streamable-http" + isStdio={false} + onClose={vi.fn()} + onSettingsChange={onSettingsChange} + />, + ); + await user.click(screen.getByRole("button", { name: "OAuth Settings" })); + await user.type( + screen.getByRole("textbox", { name: /Token URL override/i }), + "h", + ); + expect(onSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ oauthTokenUrl: "h" }), + ); + + onSettingsChange.mockClear(); + rerender( + <ServerSettingsModal + opened + settings={{ + ...emptySettings, + oauthTokenUrl: "https://staging.test/token", + }} + serverType="streamable-http" + isStdio={false} + onClose={vi.fn()} + onSettingsChange={onSettingsChange} + />, + ); + const clearButtons = screen.getAllByRole("button", { name: /clear/i }); + await user.click(clearButtons[clearButtons.length - 1]); + expect(onSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ oauthTokenUrl: undefined }), + ); + }); + // #2018 — the authorization-parameter rows ride the same onOAuthChange // callback, so the modal folds them onto the settings object. it("persists an added authorization-parameter row onto the settings", async () => { diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index 869291b1..35c1fb12 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -183,6 +183,11 @@ export function ServerSettingsModal({ // survives a re-render; the drop-blank/omit-empty filtering happens on the // way to disk. An emptied list persists as `[]`, which writes nothing. oauthAuthorizationParams: oauth.authorizationParams, + // #1906: an emptied field persists as undefined rather than `""` — the + // read side treats a blank override as "not configured", and keeping the + // two in step means clearing the input really clears the setting. + oauthAuthorizationUrl: oauth.authorizationUrl || undefined, + oauthTokenUrl: oauth.tokenUrl || undefined, enterpriseManaged: oauth.enterpriseManaged ? true : undefined, // SEP-2350: persist only the non-default ('throw') so unset servers keep // the SDK's `reauthorize` behavior without writing a spurious field. diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts new file mode 100644 index 00000000..7403ce97 --- /dev/null +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -0,0 +1,276 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + applyOAuthEndpointOverrides, + isAuthorizationServerMetadata, + normalizeOAuthEndpointOverrides, + oauthEndpointUrlError, + withOAuthEndpointOverrides, +} from "@inspector/core/auth/endpointOverrides.js"; + +const METADATA = { + issuer: "https://as.example.com", + authorization_endpoint: "https://as.example.com/authorize", + token_endpoint: "https://as.example.com/token", + response_types_supported: ["code"], +}; + +const STAGING_AUTHORIZE = "https://staging.example.com/authorize"; +const STAGING_TOKEN = "https://staging.example.com/token"; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(typeof body === "string" ? body : JSON.stringify(body), { + headers: { "content-type": "application/json" }, + ...init, + }); +} + +/** A `fetch` that always resolves to the same prepared response. */ +function passThrough(response: Response): typeof fetch { + return async () => response; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("oauthEndpointUrlError", () => { + it("accepts a blank value as 'no override'", () => { + expect(oauthEndpointUrlError("")).toBeUndefined(); + expect(oauthEndpointUrlError(" ")).toBeUndefined(); + }); + + it("accepts absolute http and https URLs", () => { + expect(oauthEndpointUrlError(STAGING_AUTHORIZE)).toBeUndefined(); + expect( + oauthEndpointUrlError("http://localhost:9000/token"), + ).toBeUndefined(); + }); + + it("rejects a relative path", () => { + expect(oauthEndpointUrlError("/authorize")).toMatch(/not an absolute URL/); + }); + + it("rejects a non-http(s) scheme", () => { + expect(oauthEndpointUrlError("ftp://example.com/authorize")).toMatch( + /not an http\(s\) URL/, + ); + }); +}); + +describe("normalizeOAuthEndpointOverrides", () => { + it("returns undefined when nothing is configured", () => { + expect(normalizeOAuthEndpointOverrides(undefined)).toBeUndefined(); + expect(normalizeOAuthEndpointOverrides({})).toBeUndefined(); + expect( + normalizeOAuthEndpointOverrides({ authorizationUrl: " " }), + ).toBeUndefined(); + }); + + it("trims and keeps each configured endpoint independently", () => { + expect( + normalizeOAuthEndpointOverrides({ + authorizationUrl: ` ${STAGING_AUTHORIZE} `, + }), + ).toEqual({ authorizationUrl: STAGING_AUTHORIZE }); + expect( + normalizeOAuthEndpointOverrides({ tokenUrl: STAGING_TOKEN }), + ).toEqual({ tokenUrl: STAGING_TOKEN }); + }); + + it("drops a malformed value with a warning, keeping the valid sibling", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect( + normalizeOAuthEndpointOverrides({ + authorizationUrl: "not a url", + tokenUrl: STAGING_TOKEN, + }), + ).toEqual({ tokenUrl: STAGING_TOKEN }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Ignoring `authorizationUrl`"), + ); + }); + + it("returns undefined when every configured value is malformed", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + expect( + normalizeOAuthEndpointOverrides({ + authorizationUrl: "ftp://example.com/a", + tokenUrl: "also not a url", + }), + ).toBeUndefined(); + }); +}); + +describe("isAuthorizationServerMetadata", () => { + it("accepts a document with an issuer and at least one endpoint", () => { + expect(isAuthorizationServerMetadata(METADATA)).toBe(true); + expect( + isAuthorizationServerMetadata({ + issuer: "https://as.example.com", + token_endpoint: "https://as.example.com/token", + }), + ).toBe(true); + }); + + it("rejects a non-object body", () => { + expect(isAuthorizationServerMetadata(null)).toBe(false); + expect(isAuthorizationServerMetadata("nope")).toBe(false); + expect(isAuthorizationServerMetadata([METADATA])).toBe(false); + }); + + it("rejects protected-resource metadata, which has no issuer", () => { + expect( + isAuthorizationServerMetadata({ + resource: "https://mcp.example.com", + authorization_servers: ["https://as.example.com"], + }), + ).toBe(false); + }); + + it("rejects a document with an issuer but neither endpoint", () => { + expect( + isAuthorizationServerMetadata({ issuer: "https://as.example.com" }), + ).toBe(false); + }); +}); + +describe("applyOAuthEndpointOverrides", () => { + it("replaces only the configured endpoints and copies the rest", () => { + const patched = applyOAuthEndpointOverrides(METADATA, { + tokenUrl: STAGING_TOKEN, + }); + expect(patched).toEqual({ + ...METADATA, + token_endpoint: STAGING_TOKEN, + }); + expect(METADATA.token_endpoint).toBe("https://as.example.com/token"); + }); + + it("supplies an endpoint the document never advertised", () => { + const patched = applyOAuthEndpointOverrides( + { issuer: "https://as.example.com" }, + { authorizationUrl: STAGING_AUTHORIZE, tokenUrl: STAGING_TOKEN }, + ); + expect(patched).toEqual({ + issuer: "https://as.example.com", + authorization_endpoint: STAGING_AUTHORIZE, + token_endpoint: STAGING_TOKEN, + }); + }); +}); + +describe("withOAuthEndpointOverrides", () => { + it("rewrites the endpoints of a metadata response", async () => { + const base: typeof fetch = async () => jsonResponse(METADATA); + const wrapped = withOAuthEndpointOverrides(base, () => ({ + authorizationUrl: STAGING_AUTHORIZE, + tokenUrl: STAGING_TOKEN, + })); + + const response = await wrapped("https://as.example.com/.well-known/x"); + await expect(response.json()).resolves.toEqual({ + ...METADATA, + authorization_endpoint: STAGING_AUTHORIZE, + token_endpoint: STAGING_TOKEN, + }); + expect(response.status).toBe(200); + }); + + it("re-reads the overrides on every call, so a settings edit takes effect", async () => { + const base: typeof fetch = async () => jsonResponse(METADATA); + // Held in a box rather than a `let`: the resolver closes over it before the + // first assignment, which is what the lazy read exists to support. + const config: { overrides?: { tokenUrl?: string } } = {}; + const wrapped = withOAuthEndpointOverrides(base, () => config.overrides); + + const before = await wrapped("https://as.example.com/.well-known/x"); + await expect(before.json()).resolves.toMatchObject({ + token_endpoint: METADATA.token_endpoint, + }); + + config.overrides = { tokenUrl: STAGING_TOKEN }; + const after = await wrapped("https://as.example.com/.well-known/x"); + await expect(after.json()).resolves.toMatchObject({ + token_endpoint: STAGING_TOKEN, + }); + }); + + it("warns once about a malformed override, not on every request", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const base: typeof fetch = async () => jsonResponse(METADATA); + const wrapped = withOAuthEndpointOverrides(base, () => ({ + tokenUrl: "not a url", + })); + + await wrapped("https://as.example.com/x"); + await wrapped("https://as.example.com/x"); + await wrapped("https://as.example.com/x"); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("passes the response through untouched when nothing is configured", async () => { + const original = jsonResponse(METADATA); + const wrapped = withOAuthEndpointOverrides( + passThrough(original), + () => undefined, + ); + await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + }); + + it("leaves an error response alone", async () => { + const original = jsonResponse(METADATA, { status: 404 }); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); + await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + }); + + it("leaves a non-JSON response alone", async () => { + const original = new Response("<html></html>", { + headers: { "content-type": "text/html" }, + }); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); + await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + }); + + it("preserves a JSON body that is not metadata, and stays readable", async () => { + const wrapped = withOAuthEndpointOverrides( + passThrough(jsonResponse({ access_token: "abc" })), + () => ({ tokenUrl: STAGING_TOKEN }), + ); + const response = await wrapped("https://as.example.com/token"); + await expect(response.json()).resolves.toEqual({ access_token: "abc" }); + }); + + it("hands back a readable response when the body is not valid JSON", async () => { + const wrapped = withOAuthEndpointOverrides( + passThrough(jsonResponse("not json at all")), + () => ({ tokenUrl: STAGING_TOKEN }), + ); + const response = await wrapped("https://as.example.com/x"); + await expect(response.text()).resolves.toBe("not json at all"); + }); + + it("drops the stale content-length and content-encoding of a rewritten body", async () => { + const body = JSON.stringify(METADATA); + const wrapped = withOAuthEndpointOverrides( + passThrough( + new Response(body, { + headers: { + "content-type": "application/json", + "content-length": String(body.length), + "content-encoding": "gzip", + }, + }), + ), + () => ({ tokenUrl: STAGING_TOKEN }), + ); + const response = await wrapped("https://as.example.com/x"); + expect(response.headers.get("content-length")).toBeNull(); + expect(response.headers.get("content-encoding")).toBeNull(); + expect(response.headers.get("content-type")).toBe("application/json"); + }); +}); diff --git a/clients/web/src/test/core/client/runner.test.ts b/clients/web/src/test/core/client/runner.test.ts index 3936d6c2..06dd0e1b 100644 --- a/clients/web/src/test/core/client/runner.test.ts +++ b/clients/web/src/test/core/client/runner.test.ts @@ -124,6 +124,46 @@ describe("runner client auth options", () => { expect(buildRunnerClientAuthOptions({}, settings)).toEqual({}); }); + // #1906 — the CLI/TUI leg reads the endpoint overrides from the same + // per-server settings the web leg does. + it("buildRunnerClientAuthOptions forwards the endpoint overrides", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationUrl: "https://staging.example.com/authorize", + oauthTokenUrl: "https://staging.example.com/token", + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + const opts = buildRunnerClientAuthOptions({}, settings); + expect(opts.oauth?.authorizationUrl).toBe( + "https://staging.example.com/authorize", + ); + expect(opts.oauth?.tokenUrl).toBe("https://staging.example.com/token"); + }); + + it("buildRunnerClientAuthOptions ignores blank endpoint overrides", () => { + const settings: InspectorServerSettings = { + oauthAuthorizationUrl: " ", + oauthTokenUrl: "", + requestTimeout: 0, + connectionTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 10, + autoRefreshOnListChanged: false, + metadata: [], + headers: [], + env: [], + roots: [], + }; + expect(buildRunnerClientAuthOptions({}, settings)).toEqual({}); + }); + it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => { expect(buildRunnerClientAuthOptions({})).toEqual({}); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-oauth-endpoint-overrides.test.ts b/clients/web/src/test/core/mcp/inspectorClient-oauth-endpoint-overrides.test.ts new file mode 100644 index 00000000..c8dd11a8 --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-oauth-endpoint-overrides.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { CreateTransportOptions } from "@inspector/core/mcp/types.js"; +import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; + +/** + * #1906 — the per-server authorization/token endpoint overrides are applied by + * wrapping the client's base fetch, so the authorization-server metadata + * document carries the overridden endpoints wherever the SDK discovers it — + * including the discovery the transport runs on its own (the 401/refresh path), + * which is handed this same fetch. + * + * The assertion goes through the fetch the client actually hands the transport, + * rather than through a private field, so it fails if the wiring is removed. + */ + +class InertTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + async start(): Promise<void> {} + + async send(): Promise<void> { + throw new Error("not connected"); + } + + async close(): Promise<void> { + this.onclose?.(); + } +} + +/** + * The real browser storage plus inert navigation/redirect stubs — enough for the + * OAuth manager to exist (which is what installs the override wrapper) without + * any stored tokens, so `connect()` still builds a transport with no auth + * provider attached. + */ +function oauthEnvironment() { + return { + storage: new BrowserOAuthStorage(), + navigation: { navigateToAuthorization: () => {} }, + redirectUrlProvider: { getRedirectUrl: () => "http://localhost/callback" }, + }; +} + +const METADATA = { + issuer: "https://as.example.com", + authorization_endpoint: "https://as.example.com/authorize", + token_endpoint: "https://as.example.com/token", +}; + +/** + * Build a client, drive `connect()` far enough to create the transport, and + * return the `fetchFn` the transport was given. + */ +async function transportFetchFor( + oauth: Record<string, string> | undefined, +): Promise<typeof fetch | undefined> { + let captured: CreateTransportOptions | undefined; + const baseFetch: typeof fetch = async () => + new Response(JSON.stringify(METADATA), { + headers: { "content-type": "application/json" }, + }); + + const client = new InspectorClient( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + environment: { + fetch: baseFetch, + oauth: oauthEnvironment(), + transport: (_config, options) => { + captured = options; + return { transport: new InertTransport() }; + }, + }, + ...(oauth && { oauth }), + }, + ); + + await client.connect().catch(() => undefined); + return captured?.fetchFn; +} + +describe("InspectorClient OAuth endpoint overrides (#1906)", () => { + it("rewrites the discovered metadata endpoints for the transport's fetch", async () => { + const fetchFn = await transportFetchFor({ + authorizationUrl: "https://staging.example.com/authorize", + tokenUrl: "https://staging.example.com/token", + }); + expect(fetchFn).toBeDefined(); + + const response = await fetchFn!( + "https://as.example.com/.well-known/oauth-authorization-server", + ); + await expect(response.json()).resolves.toEqual({ + issuer: "https://as.example.com", + authorization_endpoint: "https://staging.example.com/authorize", + token_endpoint: "https://staging.example.com/token", + }); + }); + + it("leaves the metadata untouched when no override is configured", async () => { + const fetchFn = await transportFetchFor(undefined); + expect(fetchFn).toBeDefined(); + + const response = await fetchFn!( + "https://as.example.com/.well-known/oauth-authorization-server", + ); + await expect(response.json()).resolves.toEqual(METADATA); + }); + + it("picks up an override applied after construction via setOAuthConfig", async () => { + let captured: CreateTransportOptions | undefined; + const client = new InspectorClient( + { type: "streamable-http", url: "https://mcp.example/mcp" }, + { + environment: { + fetch: async () => + new Response(JSON.stringify(METADATA), { + headers: { "content-type": "application/json" }, + }), + oauth: oauthEnvironment(), + transport: (_config, options) => { + captured = options; + return { transport: new InertTransport() }; + }, + }, + oauth: { clientId: "cid" }, + }, + ); + await client.connect().catch(() => undefined); + + client.setOAuthConfig({ tokenUrl: "https://staging.example.com/token" }); + const response = await captured!.fetchFn!( + "https://as.example.com/.well-known/oauth-authorization-server", + ); + await expect(response.json()).resolves.toMatchObject({ + token_endpoint: "https://staging.example.com/token", + }); + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 99af81bf..61b1efe7 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -115,6 +115,30 @@ describe("OAuthManager", () => { }); }); + // #1906 — the endpoint overrides are read live from the config so a settings + // edit reaches the already-built fetch wrapper. + describe("getEndpointOverrides", () => { + it("returns undefined when neither endpoint is configured", () => { + const manager = new OAuthManager(createMockParams()); + expect(manager.getEndpointOverrides()).toBeUndefined(); + }); + + it("reflects each endpoint set through setOAuthConfig", () => { + const manager = new OAuthManager(createMockParams()); + manager.setOAuthConfig({ + authorizationUrl: "https://staging.test/authorize", + }); + expect(manager.getEndpointOverrides()).toEqual({ + authorizationUrl: "https://staging.test/authorize", + }); + manager.setOAuthConfig({ tokenUrl: "https://staging.test/token" }); + expect(manager.getEndpointOverrides()).toEqual({ + authorizationUrl: "https://staging.test/authorize", + tokenUrl: "https://staging.test/token", + }); + }); + }); + describe("getServerUrl propagation", () => { it("createOAuthProviderForTransport throws when getServerUrl throws", async () => { const params = createMockParams({ diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 6641e55b..09e4119c 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -12,6 +12,7 @@ import { mergeSecretsIntoStored, normalizeServerType, oauthAuthorizationParamsFromSettings, + oauthEndpointOverridesFromSettings, serverEntriesToMcpConfig, serializeMcpConfig, storedFieldsToInspectorSettings, @@ -1288,6 +1289,66 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { ).toBeUndefined(); expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined(); }); + + // #1906 — authorization/token endpoint overrides. + it("lifts oauth endpoint overrides into settings fields", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { + authorizationUrl: "https://staging.example.com/authorize", + tokenUrl: "https://staging.example.com/token", + }, + }); + expect(settings?.oauthAuthorizationUrl).toBe( + "https://staging.example.com/authorize", + ); + expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token"); + }); + + it("reads a blank endpoint override back as unset", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { clientId: "cid", authorizationUrl: "", tokenUrl: "" }, + }); + expect(settings?.oauthAuthorizationUrl).toBeUndefined(); + expect(settings?.oauthTokenUrl).toBeUndefined(); + }); + + it("persists endpoint overrides under oauth, trimming whitespace", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationUrl: " https://staging.example.com/authorize ", + oauthTokenUrl: "https://staging.example.com/token", + }); + expect(stored.oauth).toEqual({ + authorizationUrl: "https://staging.example.com/authorize", + tokenUrl: "https://staging.example.com/token", + }); + }); + + it("omits the oauth block when the endpoint overrides are blank", () => { + const stored = inspectorSettingsToStoredFields({ + ...baseSettings, + oauthAuthorizationUrl: " ", + oauthTokenUrl: "", + }); + expect(stored).not.toHaveProperty("oauth"); + }); + + it("derives the endpoint-override client option, or undefined when blank", () => { + expect( + oauthEndpointOverridesFromSettings({ + oauthAuthorizationUrl: " https://staging.example.com/authorize ", + }), + ).toEqual({ authorizationUrl: "https://staging.example.com/authorize" }); + expect( + oauthEndpointOverridesFromSettings({ + oauthTokenUrl: "https://staging.example.com/token", + }), + ).toEqual({ tokenUrl: "https://staging.example.com/token" }); + expect( + oauthEndpointOverridesFromSettings({ oauthAuthorizationUrl: " " }), + ).toBeUndefined(); + expect(oauthEndpointOverridesFromSettings({})).toBeUndefined(); + }); }); describe("envRecordToPairs / envPairsToRecord", () => { diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 18803b5a..0a286253 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -496,6 +496,75 @@ describe("/api/servers routes", () => { expect(res.status).toBe(400); }); + // #1906 — the endpoint overrides travel as plain strings and land under + // `oauth` on disk beside the credentials. + it("persists the oauth endpoint overrides on POST", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "endpointoverrides", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationUrl: "https://staging.test/authorize", + oauthTokenUrl: "https://staging.test/token", + }, + }), + }); + expect(res.status).toBe(200); + const stored = readConfig(h.configPath).mcpServers.endpointoverrides; + expect(stored?.oauth).toEqual({ + authorizationUrl: "https://staging.test/authorize", + tokenUrl: "https://staging.test/token", + }); + }); + + it("drops an empty oauth endpoint override rather than writing it", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "blankoverrides", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthAuthorizationUrl: "", + oauthTokenUrl: "", + }, + }), + }); + expect(res.status).toBe(200); + expect( + readConfig(h.configPath).mcpServers.blankoverrides, + ).not.toHaveProperty("oauth"); + }); + + it("rejects a non-string oauthTokenUrl payload", async () => { + const res = await fetch(`${h.baseUrl}/api/servers`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "badtokenurl", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + settings: { + headers: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + oauthTokenUrl: 42, + }, + }), + }); + expect(res.status).toBe(400); + }); + it("updates Inspector-extension fields at the top level on PUT", async () => { writeFileSync( h.configPath, diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts new file mode 100644 index 00000000..00528cad --- /dev/null +++ b/core/auth/endpointOverrides.ts @@ -0,0 +1,246 @@ +/** + * Per-server authorization/token endpoint overrides (#1906). + * + * The Inspector deliberately has no fields for the authorization and token URLs + * — they are resolved by authorization-server metadata discovery (RFC 8414 / + * OpenID Connect Discovery), exactly as a real MCP host resolves them. But a + * server under development often advertises its *production* authorization + * server while the person debugging it wants the staging one, and discovery + * gives them no way to say so. + * + * These overrides are that affordance: when set, they replace whatever the + * authorization server's metadata document returned. + * + * ## Why this is a fetch wrapper and not a provider hook + * + * Both endpoints reach the flow through exactly one path in SDK v2: `auth()` + * discovers the metadata document and then reads `metadata.authorization_endpoint` + * (in `startAuthorization`) and `metadata.token_endpoint` (in `fetchToken`). + * Neither is routed through `OAuthClientProvider`, and `AuthOptions` has no + * `metadata` field — so the provider seam that carries the custom authorization + * parameters (#2018) cannot reach the token endpoint at all. The one seam that + * sees both is the `fetchFn` the SDK uses for discovery, so the override is + * applied to the metadata document in flight. + * + * Two consequences worth knowing: + * + * - **A metadata document is required.** When discovery returns nothing, the SDK + * falls back to `/authorize` and `/token` on the authorization server's origin + * and there is no document to patch. That matches the feature as asked for — + * overriding "whatever urls the authorization server returns" — and a server + * publishing no metadata is already on a path where the endpoints are not + * being advertised in the first place. + * - **The Network tab shows the patched document.** The wrapper is applied to the + * base fetch, inside the request tracker, so what the tab renders is the + * metadata as the flow consumed it. That is the useful reading: it explains why + * the subsequent authorize/token requests went where they did. + */ + +/** + * Per-server overrides for the two endpoints an authorization server publishes. + * Both are optional and independent — overriding only the token URL is a valid + * configuration. + */ +export interface OAuthEndpointOverrides { + /** Replaces `authorization_endpoint` in the discovered metadata. */ + authorizationUrl?: string; + /** Replaces `token_endpoint` in the discovered metadata. */ + tokenUrl?: string; +} + +/** Field name on the metadata document each override replaces. */ +const OVERRIDE_FIELDS = { + authorizationUrl: "authorization_endpoint", + tokenUrl: "token_endpoint", +} as const; + +/** + * Validation message for one configured endpoint URL, or `undefined` when the + * value is acceptable. A blank value is not an error — it means "no override". + * + * Only absolute `http:`/`https:` URLs are accepted: the value is written + * straight into the metadata document, where the SDK passes it to `new URL(...)` + * with no base — so a relative path would throw deep inside the flow, far from + * the setting that caused it. `http:` is allowed because the whole point is + * reaching a local or staging authorization server. + * + * The settings form renders this message against the field, so the form and the + * runtime cannot disagree about which values are usable. + */ +export function oauthEndpointUrlError(value: string): string | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return `"${trimmed}" is not an absolute URL.`; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return `"${trimmed}" is not an http(s) URL.`; + } + return undefined; +} + +/** + * The trimmed value, or `undefined` when it is blank or unusable. A rejected + * value warns and is dropped rather than throwing, so one bad field cannot make + * an otherwise-working server unconnectable. + */ +function normalizeEndpointUrl( + value: string | undefined, + field: keyof OAuthEndpointOverrides, +): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + if (trimmed === "") return undefined; + const error = oauthEndpointUrlError(trimmed); + if (error) { + console.warn(`[oauth] Ignoring \`${field}\`: ${error}`); + return undefined; + } + return trimmed; +} + +/** + * Normalize the configured overrides, dropping blank and malformed values. + * Returns `undefined` when nothing usable remains, which is the signal callers + * use to skip wrapping their fetch at all. + */ +export function normalizeOAuthEndpointOverrides( + overrides: OAuthEndpointOverrides | undefined, +): OAuthEndpointOverrides | undefined { + if (!overrides) return undefined; + const authorizationUrl = normalizeEndpointUrl( + overrides.authorizationUrl, + "authorizationUrl", + ); + const tokenUrl = normalizeEndpointUrl(overrides.tokenUrl, "tokenUrl"); + if (!authorizationUrl && !tokenUrl) return undefined; + return { + ...(authorizationUrl && { authorizationUrl }), + ...(tokenUrl && { tokenUrl }), + }; +} + +/** + * Whether a parsed JSON body is an authorization-server metadata document. + * + * The wrapper sees every JSON response on the connection, so this has to be + * narrow enough not to rewrite an unrelated body that happens to carry a + * similarly-named field. RFC 8414 §2 makes `issuer` REQUIRED and it appears in + * no other document the flow fetches (protected-resource metadata has + * `authorization_servers`, not `issuer`), so requiring `issuer` alongside at + * least one of the two endpoints is a precise test. + */ +export function isAuthorizationServerMetadata( + value: unknown, +): value is Record<string, unknown> { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const doc = value as Record<string, unknown>; + if (typeof doc.issuer !== "string") return false; + return ( + typeof doc.authorization_endpoint === "string" || + typeof doc.token_endpoint === "string" + ); +} + +/** + * Return a copy of an authorization-server metadata document with the + * configured endpoints replaced. + * + * An override is written even when the document does not advertise that + * endpoint: a metadata document missing `authorization_endpoint` already fails + * the flow (`new URL(undefined)` throws), so supplying the missing value is + * strictly better than leaving the hole. + */ +export function applyOAuthEndpointOverrides( + metadata: Record<string, unknown>, + overrides: OAuthEndpointOverrides, +): Record<string, unknown> { + const patched = { ...metadata }; + for (const [key, field] of Object.entries(OVERRIDE_FIELDS) as [ + keyof OAuthEndpointOverrides, + (typeof OVERRIDE_FIELDS)[keyof OAuthEndpointOverrides], + ][]) { + const override = overrides[key]; + if (override) patched[field] = override; + } + return patched; +} + +/** Rebuild a response around a replacement body, preserving status/headers. */ +function responseWithBody(response: Response, body: string): Response { + const headers = new Headers(response.headers); + // The body length changes when an override is applied, and a stale + // `content-length` on a synthesized `Response` is worse than none — consumers + // read the body we hand them, not the header. + headers.delete("content-length"); + // `fetch` already decoded the body, so the replacement is plain text and an + // inherited `content-encoding` would describe an encoding it no longer has. + headers.delete("content-encoding"); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +/** + * Wrap a `fetch` so authorization-server metadata responses carry the + * configured endpoint overrides. + * + * `getOverrides` is called per request rather than captured once: the OAuth + * config is mutable (a settings edit can change it without rebuilding the + * client), and reading it lazily means a wrapped fetch never serves a stale + * override. When it returns nothing the response is passed through untouched, + * so the wrapper is inert for the overwhelmingly common unconfigured case. + */ +export function withOAuthEndpointOverrides( + fetchFn: typeof fetch, + getOverrides: () => OAuthEndpointOverrides | undefined, +): typeof fetch { + // Normalization is memoized on the raw pair rather than run per request: it + // warns about a malformed value, and every request on the connection passes + // through here — so without this a single typo would log on every call. + let lastKey: string | undefined; + let lastNormalized: OAuthEndpointOverrides | undefined; + + const resolveOverrides = (): OAuthEndpointOverrides | undefined => { + const raw = getOverrides(); + const key = JSON.stringify([raw?.authorizationUrl, raw?.tokenUrl]); + if (key !== lastKey) { + lastKey = key; + lastNormalized = normalizeOAuthEndpointOverrides(raw); + } + return lastNormalized; + }; + + return async (input, init) => { + const response = await fetchFn(input, init); + const overrides = resolveOverrides(); + if (!overrides) return response; + if (!response.ok) return response; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("json")) return response; + + const body = await response.text(); + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + // Not JSON despite the header. The body is already consumed, so hand back + // an equivalent response rather than the drained original. + return responseWithBody(response, body); + } + if (!isAuthorizationServerMetadata(parsed)) { + return responseWithBody(response, body); + } + return responseWithBody( + response, + JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)), + ); + }; +} diff --git a/core/auth/index.ts b/core/auth/index.ts index 53615f8c..f38fcbe0 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -36,6 +36,16 @@ export { } from "./authorizationParams.js"; export type { ReservedAuthorizationParam } from "./authorizationParams.js"; +// Authorization/token endpoint overrides (#1906) +export { + oauthEndpointUrlError, + normalizeOAuthEndpointOverrides, + isAuthorizationServerMetadata, + applyOAuthEndpointOverrides, + withOAuthEndpointOverrides, +} from "./endpointOverrides.js"; +export type { OAuthEndpointOverrides } from "./endpointOverrides.js"; + // Storage export type { OAuthStorage, diff --git a/core/client/runner.ts b/core/client/runner.ts index 25cd83f3..5192b674 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -11,7 +11,10 @@ import type { InspectorClientOptions, InspectorServerSettings, } from "../mcp/types.js"; -import { oauthAuthorizationParamsFromSettings } from "../mcp/serverList.js"; +import { + oauthAuthorizationParamsFromSettings, + oauthEndpointOverridesFromSettings, +} from "../mcp/serverList.js"; import { loadClientConfig } from "./config.js"; import type { ClientConfig } from "./types.js"; import { @@ -74,6 +77,9 @@ export function buildRunnerClientAuthOptions( const serverAuthorizationParams = savedSettings ? oauthAuthorizationParamsFromSettings(savedSettings) : undefined; + const serverEndpointOverrides = savedSettings + ? oauthEndpointOverridesFromSettings(savedSettings) + : undefined; const oauthFromServer = savedSettings && @@ -81,6 +87,7 @@ export function buildRunnerClientAuthOptions( savedSettings.oauthClientSecret || savedSettings.oauthScopes || serverAuthorizationParams || + serverEndpointOverrides || savedSettings.enterpriseManaged) ? { ...(savedSettings.oauthClientId && { @@ -95,6 +102,7 @@ export function buildRunnerClientAuthOptions( ...(serverAuthorizationParams && { authorizationParams: serverAuthorizationParams, }), + ...serverEndpointOverrides, ...(savedSettings.enterpriseManaged && { enterpriseManaged: true, }), diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a635582d..09f665bd 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -218,6 +218,7 @@ import { type AuthChallengeOutcome, type HandleAuthChallengeOptions, } from "../auth/challenge.js"; +import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; @@ -650,6 +651,16 @@ export class InspectorClient extends InspectorClientEventTarget { }; // Effective auth fetch: base fetch + tracking with category 'auth' + // #1906: apply the per-server authorization/token endpoint overrides to the + // authorization-server metadata document in flight. This wraps the *base* + // fetch rather than `effectiveAuthFetch` so it also covers the discovery the + // SDK runs from inside the transport (the 401/refresh path), which is handed + // `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is + // created a few lines below, and `setOAuthConfig` can change them later — so + // the wrapper is inert until a server actually configures one. + this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () => + this.oauthManager?.getEndpointOverrides(), + ); this.effectiveAuthFetch = this.buildEffectiveAuthFetch(); this.sessionId = options.sessionId; @@ -5988,6 +5999,9 @@ export class InspectorClient extends InspectorClientEventTarget { scope?: string; /** Custom authorization-request parameters (#2018). Authorize URL only. */ authorizationParams?: Record<string, string>; + /** Endpoint overrides applied to the discovered AS metadata (#1906). */ + authorizationUrl?: string; + tokenUrl?: string; }): void { if (!this.oauthManager) { throw new Error( diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index f49aff49..b17b3d19 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -5,6 +5,7 @@ */ import { BaseOAuthClientProvider } from "../auth/providers.js"; +import type { OAuthEndpointOverrides } from "../auth/endpointOverrides.js"; import type { OAuthFlowState, OAuthStep } from "../auth/types.js"; import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; @@ -95,6 +96,8 @@ export class OAuthManager { scope?: string; enterpriseManaged?: boolean; authorizationParams?: Record<string, string>; + authorizationUrl?: string; + tokenUrl?: string; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -106,6 +109,21 @@ export class OAuthManager { return this.params.getServerUrl(); } + /** + * The per-server authorization/token endpoint overrides, read live so a + * `setOAuthConfig` between requests takes effect without rebuilding the + * client's fetch. `InspectorClient` reads this from inside its fetch wrapper. + * (#1906) + */ + getEndpointOverrides(): OAuthEndpointOverrides | undefined { + const { authorizationUrl, tokenUrl } = this.oauthConfig; + if (!authorizationUrl && !tokenUrl) return undefined; + return { + ...(authorizationUrl && { authorizationUrl }), + ...(tokenUrl && { tokenUrl }), + }; + } + /** * Return the configured OAuth storage, throwing a clear error if it is * absent. Used on the EMA re-mint/persist paths, which are only reached for diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index d0ee6ea1..02e3423f 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -1131,11 +1131,22 @@ export function createRemoteApp( clientSecret?: string; scopes?: string; authorizationParams?: Record<string, string>; + authorizationUrl?: string; + tokenUrl?: string; enterpriseManaged?: boolean; } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; const o = v as Record<string, unknown>; - for (const k of ["clientId", "clientSecret", "scopes"] as const) { + for (const k of [ + "clientId", + "clientSecret", + "scopes", + // #1906 — shape only; the values are validated as absolute http(s) URLs + // where they're applied (core/auth/endpointOverrides.ts), so a typo drops + // one field with a warning instead of dropping the whole `oauth` block. + "authorizationUrl", + "tokenUrl", + ] as const) { if (o[k] !== undefined && typeof o[k] !== "string") return false; } if ( @@ -1255,7 +1266,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, enterpriseManaged?, onInsufficientScope? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, authorizationParams?, authorizationUrl?, tokenUrl?, enterpriseManaged?, onInsufficientScope? }`.", ); delete valObj.oauth; } @@ -1522,6 +1533,10 @@ export function createRemoteApp( "oauthClientId", "oauthClientSecret", "oauthScopes", + // #1906 — string-shaped on the wire; URL validity is checked where the + // override is applied, not here. + "oauthAuthorizationUrl", + "oauthTokenUrl", ] as const) { if (obj[optional] !== undefined && typeof obj[optional] !== "string") { return { ok: false, error: `settings.${optional} must be a string` }; @@ -1660,6 +1675,18 @@ export function createRemoteApp( if (isKvArray(obj.oauthAuthorizationParams)) { value.oauthAuthorizationParams = obj.oauthAuthorizationParams; } + // Empty-string coerces to absent like the credential fields above: the form + // emits `""` when a user clears the input, and an empty override on disk + // would read back as a configured-but-blank endpoint. (#1906) + if ( + typeof obj.oauthAuthorizationUrl === "string" && + obj.oauthAuthorizationUrl !== "" + ) { + value.oauthAuthorizationUrl = obj.oauthAuthorizationUrl; + } + if (typeof obj.oauthTokenUrl === "string" && obj.oauthTokenUrl !== "") { + value.oauthTokenUrl = obj.oauthTokenUrl; + } if (obj.enterpriseManaged === true) { value.enterpriseManaged = true; } diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 1f34cab0..c29ba9e6 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -248,6 +248,29 @@ export function oauthAuthorizationParamsFromSettings( return Object.keys(record).length > 0 ? record : undefined; } +/** + * Collapse a server's endpoint-override fields into the + * `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set. + * Shared by the web (`App.tsx`) and Node (`buildRunnerClientAuthOptions`) paths + * so both derive the option identically. The values are passed through as + * typed; validation (absolute http(s) URL) happens once at the point of use, in + * `core/auth/endpointOverrides.ts`. (#1906) + */ +export function oauthEndpointOverridesFromSettings( + settings: Pick< + InspectorServerSettings, + "oauthAuthorizationUrl" | "oauthTokenUrl" + >, +): { authorizationUrl?: string; tokenUrl?: string } | undefined { + const authorizationUrl = settings.oauthAuthorizationUrl?.trim(); + const tokenUrl = settings.oauthTokenUrl?.trim(); + if (!authorizationUrl && !tokenUrl) return undefined; + return { + ...(authorizationUrl && { authorizationUrl }), + ...(tokenUrl && { tokenUrl }), + }; +} + /** * Lift the Inspector-extension fields off a freshly-read `StoredMCPServer` * into the pair-array / flat-OAuth `InspectorServerSettings` shape the form @@ -357,6 +380,11 @@ export function storedFieldsToInspectorSettings( if (authorizationParams) { settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams); } + // Truthiness drops empty strings like the credential fields above, so a + // cleared field reads back as unset rather than as an empty override. (#1906) + if (stored.oauth?.authorizationUrl) + settings.oauthAuthorizationUrl = stored.oauth.authorizationUrl; + if (stored.oauth?.tokenUrl) settings.oauthTokenUrl = stored.oauth.tokenUrl; if (stored.oauth?.onInsufficientScope) { settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope; } @@ -474,6 +502,14 @@ export function inspectorSettingsToStoredFields( oauthFields.authorizationParams = authParams; } } + // Empty/blank reads back as unset (above), so a cleared field writes nothing + // and the file diff stays minimal for servers that never set one. (#1906) + if (settings.oauthAuthorizationUrl?.trim()) { + oauthFields.authorizationUrl = settings.oauthAuthorizationUrl.trim(); + } + if (settings.oauthTokenUrl?.trim()) { + oauthFields.tokenUrl = settings.oauthTokenUrl.trim(); + } if (settings.oauthOnInsufficientScope) { oauthFields.onInsufficientScope = settings.oauthOnInsufficientScope; } diff --git a/core/mcp/types.ts b/core/mcp/types.ts index ece4e1be..3b92eb2c 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -176,6 +176,19 @@ export type StoredMCPServer = MCPServerConfig & { * (#2018) */ authorizationParams?: Record<string, string>; + /** + * Overrides the `authorization_endpoint` the authorization server's + * metadata document advertises — for pointing a server at a development or + * staging authorization server without changing what it publishes. + * Inspector-specific. (#1906) + */ + authorizationUrl?: string; + /** + * Overrides the `token_endpoint` the authorization server's metadata + * document advertises. Independent of {@link authorizationUrl}. + * Inspector-specific. (#1906) + */ + tokenUrl?: string; }; /** * Filesystem/URI roots advertised to the server via the `roots` client @@ -534,6 +547,13 @@ export interface OAuthSettings { * (#2018) */ authorizationParams?: { key: string; value: string }[]; + /** + * Endpoint overrides applied to the discovered authorization-server metadata + * (#1906). Optional for the same reason as `authorizationParams` — callers + * building an `OAuthSettings` for the other fields need not supply them. + */ + authorizationUrl?: string; + tokenUrl?: string; enterpriseManaged?: boolean; onInsufficientScope?: OnInsufficientScopePolicy; } @@ -699,6 +719,18 @@ export interface InspectorServerSettings { * the way there. Applied to the authorization request only. (#2018) */ oauthAuthorizationParams?: { key: string; value: string }[]; + /** + * Overrides the authorization endpoint discovery resolved, so a server can be + * pointed at a development or staging authorization server without changing + * what it advertises. Empty/absent means "use what discovery returned". + * Persisted as `oauth.authorizationUrl`. (#1906) + */ + oauthAuthorizationUrl?: string; + /** + * Overrides the token endpoint discovery resolved. Independent of + * {@link oauthAuthorizationUrl}; persisted as `oauth.tokenUrl`. (#1906) + */ + oauthTokenUrl?: string; /** * SEP-2350 step-up policy for a `403 insufficient_scope` challenge on this * server's HTTP transport. Defaults to `reauthorize` when unset. @@ -1064,6 +1096,13 @@ export interface InspectorClientOptions { * (#2018). Reserved, protocol-critical keys are dropped with a warning. */ authorizationParams?: Record<string, string>; + /** + * Overrides for the endpoints the authorization server's metadata document + * advertises (#1906). Applied to the discovered document, so they reach both + * the authorization request and the token request. + */ + authorizationUrl?: string; + tokenUrl?: string; }; /** diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index 1fa58a7a..fa3e9409 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -175,10 +175,14 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w | `paginatedLists` | `false` | Fetch tools/resources/prompts one page at a time instead of auto-aggregating | | `advertisedExtensions` | — | Per-extension overrides for what the Inspector declares in `capabilities.extensions` | | `maxFetchRequests` | `1000` | Network-log retention for this server (`DEFAULT_MAX_FETCH_REQUESTS`); `0` means unlimited | -| `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, enterpriseManaged, onInsufficientScope }` | +| `oauth` | — | `{ clientId, clientSecret, scopes, authorizationParams, authorizationUrl, tokenUrl, enterpriseManaged, onInsufficientScope }` | `oauth.authorizationParams` is a string→string record of extra query parameters merged into the OAuth **authorization request** URL only — never the token request. Use it for provider-specific hints the core specs don't standardize (Keycloak's `kc_idp_hint`, OIDC's `login_hint` / `prompt` / `acr_values`, Auth0's `audience`). The protocol-critical parameters — `client_id`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `resource`, `response_type`, `scope`, `state` — are **reserved**: the web form rejects them inline, and any that reach the merge anyway are dropped with a warning rather than overriding what the flow set (overriding them breaks PKCE, the CSRF state binding, or RFC 8707). Edit them in Server Settings → Authorization ("Additional authorization parameters"), beside Scopes. +`oauth.authorizationUrl` and `oauth.tokenUrl` override the `authorization_endpoint` and `token_endpoint` that authorization-server metadata discovery resolved. The Inspector deliberately has no such fields by default — it resolves both from the AS's metadata document, exactly as a real MCP host does — but a server under development often advertises its *production* authorization server while you want to hit staging. Set either (they are independent) to an absolute `http(s)` URL and it replaces what the metadata returned, for both the authorization request and the token request; leave blank to use discovery. A malformed value is flagged inline in the form and dropped with a warning at connect time rather than failing the connection. Edit them in Server Settings → Authorization ("Authorization URL override" / "Token URL override"). + +> Because the override is applied to the *discovered metadata document*, a server whose authorization server publishes no metadata at all is unaffected — there the SDK falls back to `/authorize` and `/token` on the AS origin, and there is nothing to override. + A catalog carrying these fields: ```json From 7a29a2161800ab19a47e02be9ffdd6fe3003245a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 20:47:01 -0400 Subject: [PATCH 180/941] test(web): drop the double cast from the CRLF SSE stream mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both new #2006 regressions faked the events response with an object cast `as unknown as Response`, which AGENTS.md rejects — it erases the check that the mock actually satisfies the API the hook consumes. Each now builds a real `ReadableStream` inside a real `Response`. `pull` runs once per chunk the hook's reader consumes, so awaiting a promise inside it gates delivery exactly the way the hand-rolled reader did, while the mock stays type-checked against the Response contract. The chunk counter replaces the read counter as the "the reader has consumed the previous chunk" signal. Both still fail against the pre-fix parser, verified. Raised by Copilot on PR #2036. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../src/test/core/react/useServers.test.tsx | 72 +++++++++---------- 1 file changed, 33 insertions(+), 39 deletions(-) diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index e7c2fbf3..c0d5260c 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -987,7 +987,6 @@ describe("useServers", () => { const framed = new Promise<void>((r) => { releaseFrame = r; }); - let reads = 0; const encoder = new TextEncoder(); // Referentially stable: an inline arrow re-subscribes on every render, and // the re-subscription's own mount refresh would pick the edit up on its @@ -995,22 +994,18 @@ describe("useServers", () => { const fetchFn: typeof fetch = async (input, init) => { const url = input instanceof Request ? input.url : String(input); if (url.endsWith("/api/servers/events")) { - const body = { - getReader: () => ({ - read: async () => { - reads += 1; - if (reads === 1) { - await framed; - return { - done: false, - value: encoder.encode("event: change\r\n\r\n"), - }; - } - return { done: true, value: undefined }; - }, - }), - }; - return { ok: true, body } as unknown as Response; + // A real ReadableStream in a real Response — `pull` is only invoked + // when the hook's reader asks for a chunk, so awaiting inside it + // gates delivery exactly the way a hand-rolled reader double would, + // while keeping the mock type-checked against the Response API. + const stream = new ReadableStream<Uint8Array>({ + async pull(controller) { + await framed; + controller.enqueue(encoder.encode("event: change\r\n\r\n")); + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); } return h.fetchFn(url, init); }; @@ -1057,31 +1052,30 @@ describe("useServers", () => { releaseSecondHalf = r; }); let listGets = 0; - let reads = 0; + let chunks = 0; const encoder = new TextEncoder(); const fetchFn: typeof fetch = async (input, init) => { const url = input instanceof Request ? input.url : String(input); if (url.endsWith("/api/servers/events")) { - const body = { - getReader: () => ({ - read: async () => { - reads += 1; - if (reads === 1) { - await firstHalf; - return { - done: false, - value: encoder.encode("event: change\r\n\r"), - }; - } - if (reads === 2) { - await secondHalf; - return { done: false, value: encoder.encode("\n") }; - } - return { done: true, value: undefined }; - }, - }), - }; - return { ok: true, body } as unknown as Response; + // `pull` runs once per chunk the hook's reader consumes, so `chunks` + // reaching 2 proves the half-separator chunk was read and parsed. + const stream = new ReadableStream<Uint8Array>({ + async pull(controller) { + chunks += 1; + if (chunks === 1) { + await firstHalf; + controller.enqueue(encoder.encode("event: change\r\n\r")); + return; + } + if (chunks === 2) { + await secondHalf; + controller.enqueue(encoder.encode("\n")); + return; + } + controller.close(); + }, + }); + return new Response(stream, { status: 200 }); } if (url.endsWith("/api/servers")) listGets += 1; return h.fetchFn(url, init); @@ -1094,7 +1088,7 @@ describe("useServers", () => { // Half a separator is not a frame: no refresh yet. releaseFirstHalf?.(); - await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); + await waitFor(() => expect(chunks).toBeGreaterThanOrEqual(2)); await new Promise((r) => setTimeout(r, 100)); expect(listGets).toBe(1); From 9aab94596438c4da83f801d024f622e007a954f9 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 20:57:02 -0400 Subject: [PATCH 181/941] feat(web): custom headers on the manual server-config form (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a server by hand offered no way to set custom HTTP headers, so the only route to a cookie or a routing header was importing a config file. The manual "Add server" form now carries a Custom Headers key/value editor for the sse / streamable-http transports, pre-populated when editing or cloning and submitted alongside the config. Headers are not part of `MCPServerConfig` — they live on the entry's `settings` — so they travel as `onSubmit`'s third argument rather than folded into the config, and `addServer` / `updateServer` gained an optional `settings` parameter to carry them to the backend (`POST /api/servers` already accepted one). `updateServer`'s omission semantics are unchanged: with no settings passed, the route still preserves the node on disk. `KeyValueRows` moves out of ServerSettingsForm into a shared element so the two editors cannot drift, and each row's controls gain a row-scoped `aria-label` ("header value, Cookie", "Remove header, Cookie") — previously every remove button announced only "X", indistinguishable across rows. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 61 ++++-- .../KeyValueRows/KeyValueRows.stories.tsx | 60 ++++++ .../KeyValueRows/KeyValueRows.test.tsx | 87 +++++++++ .../elements/KeyValueRows/KeyValueRows.tsx | 91 +++++++++ .../ServerConfigModal.stories.tsx | 40 +++- .../ServerConfigModal.test.tsx | 180 ++++++++++++++++-- .../ServerConfigModal/ServerConfigModal.tsx | 128 ++++++++++--- .../ServerSettingsForm.test.tsx | 20 +- .../ServerSettingsForm/ServerSettingsForm.tsx | 51 +---- .../ServerSettingsModal.test.tsx | 19 +- .../src/test/core/react/useServers.test.tsx | 87 +++++++++ core/react/useServers.ts | 44 ++++- 12 files changed, 732 insertions(+), 136 deletions(-) create mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx create mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx create mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 72d8bd1f..e3d2557a 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -138,6 +138,7 @@ import { ServerConfigModal, type ServerConfigModalMode, } from "./components/groups/ServerConfigModal/ServerConfigModal"; +import type { KeyValuePair } from "./components/elements/KeyValueRows/KeyValueRows"; import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; import { ClientSettingsModal } from "./components/groups/ClientSettingsModal/ClientSettingsModal"; import { @@ -3895,30 +3896,17 @@ function App() { // each id), deduped. The batch is reset to empty when an add/import modal // opens (see the menu handlers). const addServerHighlighted = useCallback( - async (id: string, config: MCPServerConfig) => { - await addServer(id, config); + async ( + id: string, + config: MCPServerConfig, + settings?: InspectorServerSettings, + ) => { + await addServer(id, config, settings); setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id])); }, [addServer], ); - // On rename of the active server, keep activeServerId pointed at the new id. - const onConfigSubmit = useCallback( - async (id: string, config: MCPServerConfig) => { - if (configModal?.mode === "edit" && configModal.targetId) { - const originalId = configModal.targetId; - await updateServer(originalId, id, config); - if (originalId === activeServerId && id !== originalId) { - setActiveServerId(id); - } - return; - } - // add or clone - await addServerHighlighted(id, config); - }, - [configModal, addServerHighlighted, updateServer, activeServerId], - ); - // Derive the existingIds list the modal uses for uniqueness validation. // In edit mode the target's own id must be excluded so saving without // renaming doesn't trip the "already exists" check. @@ -3935,6 +3923,40 @@ function App() { return servers.find((s) => s.id === configModal.targetId); }, [configModal, servers]); + // On rename of the active server, keep activeServerId pointed at the new id. + const onConfigSubmit = useCallback( + async (id: string, config: MCPServerConfig, headers: KeyValuePair[]) => { + // Headers live on the entry's `settings`, not on the transport config, + // so they're folded back in here (#1915). Send `settings` only when it + // would change something: with no headers either way there is nothing to + // write, and omitting the key is what makes the backend preserve the + // rest of the settings node. + const existing = configModalTarget?.settings; + const settingsChanged = + headers.length > 0 || (existing?.headers.length ?? 0) > 0; + const settings = settingsChanged + ? { ...(existing ?? EMPTY_SETTINGS), headers } + : undefined; + if (configModal?.mode === "edit" && configModal.targetId) { + const originalId = configModal.targetId; + await updateServer(originalId, id, config, settings); + if (originalId === activeServerId && id !== originalId) { + setActiveServerId(id); + } + return; + } + // add or clone + await addServerHighlighted(id, config, settings); + }, + [ + configModal, + configModalTarget, + addServerHighlighted, + updateServer, + activeServerId, + ], + ); + const settingsModalTarget = useMemo(() => { if (!settingsModalTargetId) return undefined; return servers.find((s) => s.id === settingsModalTargetId); @@ -4550,6 +4572,7 @@ function App() { mode={configModal?.mode ?? "add"} initialId={configModalTarget?.id} initialConfig={configModalTarget?.config} + initialHeaders={configModalTarget?.settings?.headers} existingIds={existingIds} onClose={() => setConfigModal(null)} onSubmit={onConfigSubmit} diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx new file mode 100644 index 00000000..05c5ca14 --- /dev/null +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx @@ -0,0 +1,60 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import { KeyValueRows } from "./KeyValueRows"; + +const meta: Meta<typeof KeyValueRows> = { + title: "Elements/KeyValueRows", + component: KeyValueRows, + args: { + entityLabel: "header", + onChange: fn(), + onRemove: fn(), + }, +}; + +export default meta; +type Story = StoryObj<typeof KeyValueRows>; + +// Each row's controls carry a row-scoped accessible name, so a screen reader +// can tell one row's key box from another's. +export const Populated: Story = { + args: { + items: [ + { key: "Cookie", value: "branch=feature-x" }, + { key: "X-Env", value: "dev" }, + ], + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const value = await canvas.findByRole("textbox", { + name: "header value, Cookie", + }); + await expect(value).toHaveValue("branch=feature-x"); + + await userEvent.click( + canvas.getByRole("button", { name: "Remove header, X-Env" }), + ); + await expect(args.onRemove).toHaveBeenCalledWith(1); + }, +}; + +// A row whose key is still blank falls back to a positional name rather than +// announcing nothing. +export const BlankKeyRow: Story = { + args: { items: [{ key: "", value: "" }] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByRole("textbox", { name: "header name, row 1" }), + ).toBeInTheDocument(); + }, +}; + +// An empty list renders nothing — callers draw their own empty-state hint. +export const Empty: Story = { + args: { items: [] }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryAllByRole("textbox")).toHaveLength(0); + }, +}; diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx new file mode 100644 index 00000000..ef96bb8d --- /dev/null +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { KeyValueRows } from "./KeyValueRows"; + +describe("KeyValueRows", () => { + function setup(items: { key: string; value: string }[]) { + const onChange = vi.fn(); + const onRemove = vi.fn(); + renderWithMantine( + <KeyValueRows + items={items} + entityLabel="header" + onChange={onChange} + onRemove={onRemove} + />, + ); + return { onChange, onRemove }; + } + + it("renders nothing for an empty list", () => { + setup([]); + expect(screen.queryAllByRole("textbox")).toHaveLength(0); + }); + + it("names each control by entity and row so rows are distinguishable", () => { + setup([ + { key: "Cookie", value: "a=1" }, + { key: "", value: "" }, + ]); + expect( + screen.getByRole("textbox", { name: "header name, Cookie" }), + ).toHaveValue("Cookie"); + expect( + screen.getByRole("textbox", { name: "header value, Cookie" }), + ).toHaveValue("a=1"); + // A key that is blank (or only whitespace) falls back to its position. + expect( + screen.getByRole("textbox", { name: "header name, row 2" }), + ).toBeInTheDocument(); + }); + + it("reports key and value edits with the row index", async () => { + const user = userEvent.setup({ delay: null }); + const { onChange } = setup([{ key: "X", value: "1" }]); + + await user.type( + screen.getByRole("textbox", { name: "header name, X" }), + "Y", + ); + expect(onChange).toHaveBeenLastCalledWith(0, "XY", "1"); + + await user.type( + screen.getByRole("textbox", { name: "header value, X" }), + "2", + ); + expect(onChange).toHaveBeenLastCalledWith(0, "X", "12"); + }); + + it("clears a key or a value through its Clear button", async () => { + const user = userEvent.setup({ delay: null }); + const { onChange } = setup([{ key: "X", value: "1" }]); + + const clearButtons = screen.getAllByRole("button", { name: "Clear" }); + await user.click(clearButtons[0]!); + expect(onChange).toHaveBeenLastCalledWith(0, "", "1"); + + await user.click(clearButtons[1]!); + expect(onChange).toHaveBeenLastCalledWith(0, "X", ""); + }); + + it("omits the Clear button for an empty key or value", () => { + setup([{ key: "", value: "" }]); + expect(screen.queryAllByRole("button", { name: "Clear" })).toHaveLength(0); + }); + + it("removes the clicked row", async () => { + const user = userEvent.setup({ delay: null }); + const { onRemove } = setup([ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ]); + + await user.click(screen.getByRole("button", { name: "Remove header, B" })); + expect(onRemove).toHaveBeenCalledWith(1); + }); +}); diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx new file mode 100644 index 00000000..c41bd9c3 --- /dev/null +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx @@ -0,0 +1,91 @@ +import { ActionIcon, Group, TextInput } from "@mantine/core"; +import { ClearButton } from "../ClearButton/ClearButton"; + +export interface KeyValuePair { + key: string; + value: string; +} + +export interface KeyValueRowsProps { + items: KeyValuePair[]; + /** + * Singular noun for one row ("header", "environment variable", …). Used only + * to build each control's `aria-label`: the section heading and the "Key" / + * "Value" placeholders are not programmatically associated with the inputs, + * so without it an assistive technology cannot tell one list's key box from + * another's, and the remove button announces only "X". + */ + entityLabel: string; + onChange: (index: number, key: string, value: string) => void; + onRemove: (index: number) => void; +} + +// Optional (non-required) clearable field — keeps the ClearButton clickable. +const ClearableTextInput = TextInput.withProps({ + rightSectionPointerEvents: "auto", +}); + +const RemoveIcon = ActionIcon.withProps({ + color: "red", + variant: "subtle", +}); + +/** + * Controlled editor for a list of `{ key, value }` pairs — the shape the + * Inspector persists for custom headers, request metadata, and stdio + * environment variables. Owns no state: every keystroke is reported through + * `onChange(index, key, value)` and the caller re-renders with the new list. + * + * Shared by ServerSettingsForm (headers / metadata / env) and ServerConfigModal + * (headers on the manual add form, #1915) so the two cannot drift. + */ +export function KeyValueRows({ + items, + entityLabel, + onChange, + onRemove, +}: KeyValueRowsProps) { + return ( + <> + {items.map((item, index) => { + const rowLabel = item.key.trim() || `row ${index + 1}`; + return ( + <Group key={index} grow> + <ClearableTextInput + placeholder="Key" + aria-label={`${entityLabel} name, ${rowLabel}`} + value={item.key} + onChange={(e) => + onChange(index, e.currentTarget.value, item.value) + } + rightSection={ + item.key ? ( + <ClearButton + onClick={() => onChange(index, "", item.value)} + /> + ) : null + } + /> + <ClearableTextInput + placeholder="Value" + aria-label={`${entityLabel} value, ${rowLabel}`} + value={item.value} + onChange={(e) => onChange(index, item.key, e.currentTarget.value)} + rightSection={ + item.value ? ( + <ClearButton onClick={() => onChange(index, item.key, "")} /> + ) : null + } + /> + <RemoveIcon + aria-label={`Remove ${entityLabel}, ${rowLabel}`} + onClick={() => onRemove(index)} + > + X + </RemoveIcon> + </Group> + ); + })} + </> + ); +} diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx index c180a95b..0b990f43 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx @@ -19,8 +19,8 @@ function InteractiveRender(args: ServerConfigModalProps) { args.onClose(); updateArgs({ opened: false }); }} - onSubmit={async (id, config) => { - await args.onSubmit(id, config); + onSubmit={async (id, config, headers) => { + await args.onSubmit(id, config, headers); updateArgs({ opened: false }); }} /> @@ -134,7 +134,39 @@ export const EditSse: Story = { const body = within(canvasElement.ownerDocument.body); const dialog = within(await findDialog(body, "Edit server")); await expect(await dialog.findByLabelText(/^URL/)).toBeInTheDocument(); - // Headers are no longer entered here — they live in ServerSettingsForm. - await expect(dialog.queryByLabelText(/Headers/i)).toBeNull(); + // The headers editor starts collapsed to a bare "+ Add Header" control + // when the server has none stored. + await expect( + dialog.getByRole("button", { name: "+ Add Header" }), + ).toBeInTheDocument(); + await expect( + dialog.queryAllByRole("textbox", { name: /header name/ }), + ).toHaveLength(0); + }, +}; + +// Custom headers on the manual form (#1915) — the reason the field exists is a +// cookie routing requests to one developer's branch on a shared dev server. +export const EditSseWithHeaders: Story = { + args: { + mode: "edit", + initialId: "remote", + initialConfig: sseConfig, + initialHeaders: [{ key: "Cookie", value: "branch=feature-x" }], + existingIds: [], + }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const dialog = within(await findDialog(body, "Edit server")); + const value = (await dialog.findByRole("textbox", { + name: "header value, Cookie", + })) as HTMLInputElement; + await expect(value.value).toBe("branch=feature-x"); + + // Adding a row appends an empty pair, named by position until it is keyed. + await userEvent.click(dialog.getByRole("button", { name: "+ Add Header" })); + await expect( + await dialog.findByRole("textbox", { name: "header name, row 2" }), + ).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index 2f6f4e40..9bd373ef 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -66,11 +66,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Add$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith("alpha", { - type: "stdio", - command: "node", - args: ["x.js", "--port=3000"], - }); + expect(props.onSubmit).toHaveBeenCalledWith( + "alpha", + { type: "stdio", command: "node", args: ["x.js", "--port=3000"] }, + [], + ); }); it("requires a command for stdio submission", async () => { @@ -117,7 +117,7 @@ describe("ServerConfigModal", () => { expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument(); }); - it("submits an sse config with just the url (headers move to the settings form)", async () => { + it("submits an sse config with just the url and no headers", async () => { const user = userEvent.setup({ delay: null }); const props = base(); renderWithMantine( @@ -133,10 +133,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Save$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith("remote", { - type: "sse", - url: "https://x.test/sse", - }); + expect(props.onSubmit).toHaveBeenCalledWith( + "remote", + { type: "sse", url: "https://x.test/sse" }, + [], + ); }); it("submits a streamable-http config", async () => { @@ -152,10 +153,11 @@ describe("ServerConfigModal", () => { ); await user.click(screen.getByRole("button", { name: /^Save$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith("http-srv", { - type: "streamable-http", - url: "https://x.test/mcp", - }); + expect(props.onSubmit).toHaveBeenCalledWith( + "http-srv", + { type: "streamable-http", url: "https://x.test/mcp" }, + [], + ); }); it("loads the url from a streamable-http config", () => { @@ -275,11 +277,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Add$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith("alpha", { - type: "stdio", - command: "node", - cwd: "/tmp/cwd", - }); + expect(props.onSubmit).toHaveBeenCalledWith( + "alpha", + { type: "stdio", command: "node", cwd: "/tmp/cwd" }, + [], + ); }); it("clears the Server ID field via its Clear button", async () => { @@ -470,4 +472,144 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /Cancel/ })); expect(props.onClose).toHaveBeenCalledOnce(); }); + + describe("custom headers (#1915)", () => { + it("hides the headers section for stdio", () => { + renderWithMantine(<ServerConfigModal {...base()} />); + expect(screen.queryByText("Custom headers")).not.toBeInTheDocument(); + }); + + // Every keystroke re-renders the whole modal, and this case types four + // fields, so it runs several times slower under the coverage project's v8 + // instrumentation than in a plain unit run — enough to trip the 5s default. + // The extra ceiling is headroom for the instrumented run, not a hang guard. + const TYPING_HEAVY_TIMEOUT_MS = 20000; + + it( + "submits headers added on the manual add form", + async () => { + const user = userEvent.setup({ delay: null }); + const props = base(); + // Seeded on the http transport rather than driven through the + // Transport select — that select has its own test above, and walking + // its combobox here only added to the cost described above. + renderWithMantine( + <ServerConfigModal + {...props} + initialConfig={{ type: "streamable-http", url: "" }} + />, + ); + + await user.type(screen.getByLabelText(/Server ID/i), "remote"); + await user.type(screen.getByLabelText(/^URL/), "https://x.test/mcp"); + + await user.click(screen.getByRole("button", { name: "+ Add Header" })); + await user.type( + screen.getByRole("textbox", { name: /header name, row 1/ }), + "Cookie", + ); + await user.type( + screen.getByRole("textbox", { name: /header value, Cookie/ }), + "branch=feature-x", + ); + await user.click(screen.getByRole("button", { name: /^Add$/ })); + + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); + expect(props.onSubmit).toHaveBeenCalledWith( + "remote", + { type: "streamable-http", url: "https://x.test/mcp" }, + [{ key: "Cookie", value: "branch=feature-x" }], + ); + }, + TYPING_HEAVY_TIMEOUT_MS, + ); + + it("pre-populates the rows from initialHeaders and submits an edit", async () => { + const user = userEvent.setup({ delay: null }); + const props = base(); + renderWithMantine( + <ServerConfigModal + {...props} + mode="edit" + initialId="remote" + initialConfig={{ type: "sse", url: "https://x.test/sse" }} + initialHeaders={[{ key: "X-Env", value: "dev" }]} + />, + ); + + const valueInput = screen.getByRole("textbox", { + name: /header value, X-Env/, + }); + expect(valueInput).toHaveValue("dev"); + await user.type(valueInput, "-2"); + await user.click(screen.getByRole("button", { name: /^Save$/ })); + + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); + expect(props.onSubmit).toHaveBeenCalledWith( + "remote", + { type: "sse", url: "https://x.test/sse" }, + [{ key: "X-Env", value: "dev-2" }], + ); + }); + + it("drops blank-key rows and removes a row on X", async () => { + const user = userEvent.setup({ delay: null }); + const props = base(); + renderWithMantine( + <ServerConfigModal + {...props} + mode="edit" + initialId="remote" + initialConfig={{ type: "sse", url: "https://x.test/sse" }} + initialHeaders={[ + { key: "Keep", value: "1" }, + { key: "Drop", value: "2" }, + ]} + />, + ); + + // An empty row is the form's "still typing" placeholder — it must not + // reach the caller. + await user.click(screen.getByRole("button", { name: "+ Add Header" })); + // Editing one row of several must leave its siblings untouched. + await user.type( + screen.getByRole("textbox", { name: "header value, Keep" }), + "9", + ); + await user.click( + screen.getByRole("button", { name: "Remove header, Drop" }), + ); + await user.click(screen.getByRole("button", { name: /^Save$/ })); + + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); + expect(props.onSubmit).toHaveBeenCalledWith( + "remote", + { type: "sse", url: "https://x.test/sse" }, + [{ key: "Keep", value: "19" }], + ); + }); + + it("submits no headers for stdio even when initialHeaders is set", async () => { + const user = userEvent.setup({ delay: null }); + const props = base(); + renderWithMantine( + <ServerConfigModal + {...props} + mode="edit" + initialId="local" + initialConfig={{ type: "stdio", command: "node" }} + initialHeaders={[{ key: "X-Env", value: "dev" }]} + />, + ); + + await user.click(screen.getByRole("button", { name: /^Save$/ })); + + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); + expect(props.onSubmit).toHaveBeenCalledWith( + "local", + { type: "stdio", command: "node" }, + [], + ); + }); + }); }); diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index e1e823a6..24089ff5 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -10,6 +10,10 @@ import { Textarea, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { + KeyValueRows, + type KeyValuePair, +} from "../../elements/KeyValueRows/KeyValueRows"; import { useValueChange } from "../../../hooks/useValueChange"; import type { MCPServerConfig, @@ -28,11 +32,27 @@ export interface ServerConfigModalProps { initialId?: string; /** When editing or cloning, the existing config to pre-populate. */ initialConfig?: MCPServerConfig; + /** + * Custom HTTP headers already stored for the target server, pre-populated + * when editing or cloning. Headers are not part of `MCPServerConfig` — they + * live on the entry's `settings`, so they arrive (and leave, via `onSubmit`) + * as their own value rather than folded into the config. (#1915) + */ + initialHeaders?: KeyValuePair[]; /** Ids already in use — drives the uniqueness check (caller excludes the * target id from this list when in 'edit' mode). */ existingIds: string[]; onClose: () => void; - onSubmit: (id: string, config: MCPServerConfig) => Promise<void> | void; + /** + * `headers` carries the edited custom-header rows (blank-key rows already + * dropped). It is always `[]` for stdio, which has no HTTP request to attach + * headers to. + */ + onSubmit: ( + id: string, + config: MCPServerConfig, + headers: KeyValuePair[], + ) => Promise<void> | void; } type TransportChoice = "stdio" | "sse" | "streamable-http"; @@ -47,6 +67,8 @@ interface FormState { cwd: string; // sse / streamable-http url: string; + /** Custom HTTP headers — sse / streamable-http only. (#1915) */ + headers: KeyValuePair[]; } // The `string`-valued FormState keys, which all share the same text-input @@ -84,6 +106,10 @@ const EnvTextarea = Textarea.withProps({ minRows: 2, rightSectionPointerEvents: "auto", }); +const AddHeaderButton = Button.withProps({ size: "xs", variant: "light" }); +const HeadersHeader = Group.withProps({ justify: "space-between", gap: "sm" }); +const HeadersLabel = Text.withProps({ size: "sm", fw: 500 }); +const HeadersHint = Text.withProps({ size: "xs", c: "dimmed" }); const MODE_TITLES: Record<ServerConfigModalMode, string> = { add: "Add server", @@ -94,9 +120,14 @@ const MODE_TITLES: Record<ServerConfigModalMode, string> = { function configToFormState( initialId: string | undefined, initialConfig: MCPServerConfig | undefined, + initialHeaders: KeyValuePair[] | undefined, mode: ServerConfigModalMode, ): FormState { const id = mode === "edit" ? (initialId ?? "") : ""; + // Copy the rows rather than aliasing the caller's array — the form mutates + // this list as its own state, and a clone keeps a cancelled edit from + // reaching the entry the caller passed in. + const headers = (initialHeaders ?? []).map((h) => ({ ...h })); const transport: TransportChoice = initialConfig?.type === undefined ? "stdio" : initialConfig.type; if (!initialConfig) { @@ -108,6 +139,7 @@ function configToFormState( envText: "", cwd: "", url: "", + headers, }; } if (transport === "stdio") { @@ -122,9 +154,9 @@ function configToFormState( .join("\n"), cwd: c.cwd ?? "", url: "", + headers, }; } - // sse / streamable-http — custom headers live in ServerSettingsForm now. const url = initialConfig.type === "sse" || initialConfig.type === "streamable-http" ? initialConfig.url @@ -137,6 +169,7 @@ function configToFormState( envText: "", cwd: "", url: url ?? "", + headers, }; } @@ -185,13 +218,14 @@ export function ServerConfigModal({ mode, initialId, initialConfig, + initialHeaders, existingIds, onClose, onSubmit, }: ServerConfigModalProps) { const initial = useMemo( - () => configToFormState(initialId, initialConfig, mode), - [initialId, initialConfig, mode], + () => configToFormState(initialId, initialConfig, initialHeaders, mode), + [initialId, initialConfig, initialHeaders, mode], ); const [form, setForm] = useState<FormState>(initial); const [submitError, setSubmitError] = useState<string | undefined>(undefined); @@ -210,6 +244,22 @@ export function ServerConfigModal({ const clearTextField = (field: TextField) => () => setForm((f) => ({ ...f, [field]: "" })); + // Header rows are a pair array rather than a plain string, so they get their + // own handlers instead of riding `setTextField` (which `TextField` excludes + // by type). + const addHeader = () => + setForm((f) => ({ ...f, headers: [...f.headers, { key: "", value: "" }] })); + const removeHeader = (index: number) => + setForm((f) => ({ + ...f, + headers: f.headers.filter((_, i) => i !== index), + })); + const changeHeader = (index: number, key: string, value: string) => + setForm((f) => ({ + ...f, + headers: f.headers.map((h, i) => (i === index ? { key, value } : h)), + })); + // Reset the form whenever the modal opens, or whenever `initial` changes // while it is open. Keying on `opened ? initial : undefined` collapses both // triggers into one value: it flips to `initial` on open, tracks `initial` @@ -263,9 +313,9 @@ export function ServerConfigModal({ return { ok: false, error: "URL is required for sse / streamable-http." }; } const base = { url: form.url.trim() }; - // Custom headers live in ServerSettingsForm now (persisted under - // settings.headers on the entry); the SSE / streamable-http config here - // only carries the canonical transport fields. + // Custom headers are persisted under `settings.headers` on the entry, not + // on the config — they leave through `onSubmit`'s third argument, so the + // SSE / streamable-http config here carries only transport fields. const config: MCPServerConfig = form.transport === "sse" ? { type: "sse", ...base } @@ -288,9 +338,19 @@ export function ServerConfigModal({ setSubmitError(built.error); return; } + // Blank-key rows are the form's placeholder for "row being typed" and mean + // nothing on the wire — the persist layer drops them anyway, so drop them + // here too and keep the caller's payload honest. stdio has no HTTP request + // to carry headers, so it always submits none. + const headers = + form.transport === "stdio" + ? [] + : form.headers + .map((h) => ({ key: h.key.trim(), value: h.value })) + .filter((h) => h.key.length > 0); setSubmitting(true); try { - await onSubmit(trimmedId, built.config); + await onSubmit(trimmedId, built.config, headers); onClose(); } catch (err) { setSubmitError(err instanceof Error ? err.message : String(err)); @@ -412,21 +472,43 @@ export function ServerConfigModal({ /> </> ) : ( - <RequiredTextInput - label="URL" - placeholder="https://example.com/mcp" - value={form.url} - onChange={setTextField("url")} - disabled={submitting} - rightSection={ - form.url ? ( - <ClearButton - disabled={submitting} - onClick={clearTextField("url")} - /> - ) : null - } - /> + <> + <RequiredTextInput + label="URL" + placeholder="https://example.com/mcp" + value={form.url} + onChange={setTextField("url")} + disabled={submitting} + rightSection={ + form.url ? ( + <ClearButton + disabled={submitting} + onClick={clearTextField("url")} + /> + ) : null + } + /> + <FieldGrid> + <HeadersHeader> + <HeadersLabel>Custom headers</HeadersLabel> + <AddHeaderButton onClick={addHeader} disabled={submitting}> + + Add Header + </AddHeaderButton> + </HeadersHeader> + <HeadersHint> + Sent with every HTTP request to this server — cookies + included. If OAuth is configured later, the `Authorization` + header is owned by the OAuth flow and any value set here is + ignored. + </HeadersHint> + <KeyValueRows + items={form.headers} + entityLabel="header" + onChange={changeHeader} + onRemove={removeHeader} + /> + </FieldGrid> + </> )} </FieldGrid> diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index d238228a..daf31021 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -329,8 +329,9 @@ describe("ServerSettingsForm", () => { expandedSections={["headers"]} />, ); - const removeButtons = screen.getAllByRole("button", { name: "X" }); - await user.click(removeButtons[0]); + await user.click( + screen.getByRole("button", { name: "Remove header, Authorization" }), + ); expect(onRemoveHeader).toHaveBeenCalledWith(0); }); @@ -351,8 +352,9 @@ describe("ServerSettingsForm", () => { await user.type(valueInput, "Z"); expect(onMetadataChange).toHaveBeenCalled(); - const removeButtons = screen.getAllByRole("button", { name: "X" }); - await user.click(removeButtons[0]); + await user.click( + screen.getByRole("button", { name: "Remove metadata entry, userId" }), + ); expect(onRemoveMetadata).toHaveBeenCalledWith(0); }); @@ -676,9 +678,13 @@ describe("ServerSettingsForm", () => { await user.type(keyInput, "2"); expect(onEnvChange).toHaveBeenLastCalledWith(0, "API_KEY2", "secret"); - // The remove ("X") button sits alongside the row's key/value inputs. - const removeButtons = screen.getAllByRole("button", { name: "X" }); - await user.click(removeButtons[removeButtons.length - 1]!); + // The remove button sits alongside the row's key/value inputs; its + // accessible name identifies which row it belongs to. + await user.click( + screen.getByRole("button", { + name: "Remove environment variable, API_KEY", + }), + ); expect(onRemoveEnv).toHaveBeenCalledWith(0); }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 276c9cf9..22410d5e 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -13,6 +13,7 @@ import { TextInput, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; +import { KeyValueRows } from "../../elements/KeyValueRows/KeyValueRows"; import type { ProtocolEra } from "@modelcontextprotocol/client"; import type { InspectorServerSettings, @@ -223,53 +224,6 @@ const ClearStoredOAuthHint = Text.withProps({ miw: "12rem", }); -function KeyValueRows({ - items, - onChange, - onRemove, -}: { - items: { key: string; value: string }[]; - onChange: (index: number, key: string, value: string) => void; - onRemove: (index: number) => void; -}) { - /* v8 ignore next 3 -- unreachable: every caller guards with `length === 0` - and renders an EmptyHint instead, so KeyValueRows is only mounted with - a non-empty list. */ - if (items.length === 0) { - return null; - } - - return ( - <> - {items.map((item, index) => ( - <Group key={index} grow> - <ClearableTextInput - placeholder="Key" - value={item.key} - onChange={(e) => onChange(index, e.currentTarget.value, item.value)} - rightSection={ - item.key ? ( - <ClearButton onClick={() => onChange(index, "", item.value)} /> - ) : null - } - /> - <ClearableTextInput - placeholder="Value" - value={item.value} - onChange={(e) => onChange(index, item.key, e.currentTarget.value)} - rightSection={ - item.value ? ( - <ClearButton onClick={() => onChange(index, item.key, "")} /> - ) : null - } - /> - <RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon> - </Group> - ))} - </> - ); -} - // Reserved-key rejection is inline per row (#2018): the reason is passed as the // key input's `error` **string**, so Mantine renders it in the input's own error // slot and wires the `aria-describedby` association — a bare boolean would mark @@ -641,6 +595,7 @@ export function ServerSettingsForm({ ) : ( <KeyValueRows items={settings.env} + entityLabel="environment variable" onChange={onEnvChange} onRemove={onRemoveEnv} /> @@ -667,6 +622,7 @@ export function ServerSettingsForm({ ) : ( <KeyValueRows items={settings.headers} + entityLabel="header" onChange={onHeaderChange} onRemove={onRemoveHeader} /> @@ -690,6 +646,7 @@ export function ServerSettingsForm({ ) : ( <KeyValueRows items={settings.metadata} + entityLabel="metadata entry" onChange={onMetadataChange} onRemove={onRemoveMetadata} /> diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index a6faac5e..8c29c418 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -276,8 +276,9 @@ describe("ServerSettingsModal", () => { />, ); await user.click(screen.getByRole("button", { name: "Custom Headers" })); - const removeButtons = screen.getAllByRole("button", { name: "X" }); - await user.click(removeButtons[0]); + await user.click( + screen.getByRole("button", { name: "Remove header, Authorization" }), + ); expect(onSettingsChange).toHaveBeenCalledWith({ ...initialSettings, headers: [], @@ -342,10 +343,11 @@ describe("ServerSettingsModal", () => { />, ); await user.click(screen.getByRole("button", { name: "Request Metadata" })); - const removeButtons = screen.getAllByRole("button", { name: "X" }); - // After expanding metadata, both header and metadata X buttons exist; - // the metadata X is the last one. - await user.click(removeButtons[removeButtons.length - 1]); + // Both the header and metadata rows have a remove button; each is named + // for the row it belongs to, so no positional guess is needed. + await user.click( + screen.getByRole("button", { name: "Remove metadata entry, userId" }), + ); expect(onSettingsChange).toHaveBeenCalledWith({ ...initialSettings, metadata: [], @@ -764,8 +766,9 @@ describe("ServerSettingsModal", () => { await user.click( screen.getByRole("button", { name: "Environment Variables" }), ); - const removeButtons = screen.getAllByRole("button", { name: "X" }); - await user.click(removeButtons[removeButtons.length - 1]); + await user.click( + screen.getByRole("button", { name: "Remove environment variable, A" }), + ); expect(onSettingsChange).toHaveBeenCalledWith({ ...emptySettings, env: [], diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index bb95402d..49b423f2 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -123,6 +123,93 @@ describe("useServers", () => { }); }); + it("addServer persists a settings node when one is supplied (#1915)", async () => { + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + // The manual add form carries custom headers here — they live on + // `settings`, not on the transport config. + await result.current.addServer( + "remote", + { type: "streamable-http", url: "https://x.test/mcp" }, + { + headers: [{ key: "Cookie", value: "branch=feature-x" }], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + }, + ); + }); + + await waitFor(() => { + const added = result.current.servers.find((srv) => srv.id === "remote"); + expect(added?.settings?.headers).toEqual([ + { key: "Cookie", value: "branch=feature-x" }, + ]); + }); + const stored = readConfig(h.configPath).mcpServers + .remote as unknown as Record<string, unknown>; + expect(stored.headers).toEqual({ Cookie: "branch=feature-x" }); + }); + + it("updateServer replaces the settings node when one is supplied (#1915)", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + alpha: { + type: "streamable-http", + url: "https://x.test/mcp", + headers: { "X-Old": "1" }, + metadata: [{ key: "trace", value: "abc" }], + }, + }, + }), + ); + + const { result } = renderHook(() => + useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + // Editing headers in ServerConfigModal sends the whole settings node, + // so the caller is responsible for carrying the untouched fields + // (here: metadata) forward. + await result.current.updateServer( + "alpha", + "alpha", + { type: "streamable-http", url: "https://x.test/mcp" }, + { + headers: [{ key: "X-New", value: "2" }], + env: [], + metadata: [{ key: "trace", value: "abc" }], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + }, + ); + }); + + await waitFor(() => { + expect(result.current.servers[0]?.settings?.headers).toEqual([ + { key: "X-New", value: "2" }, + ]); + }); + expect(result.current.servers[0]?.settings?.metadata).toEqual([ + { key: "trace", value: "abc" }, + ]); + }); + it("importSource returns a result for a known source type", async () => { const { result } = renderHook(() => useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 3a2a5002..69935dd9 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -29,11 +29,28 @@ export interface UseServersResult { loading: boolean; error: string | undefined; refresh: () => Promise<void>; - addServer: (id: string, config: MCPServerConfig) => Promise<void>; + /** + * `settings` is optional: omitted, no settings node is persisted for the new + * entry. Passed, it is written alongside the config in the same POST — the + * add form needs this to carry custom headers, which live on `settings` + * rather than on the transport config (#1915). + */ + addServer: ( + id: string, + config: MCPServerConfig, + settings?: InspectorServerSettings, + ) => Promise<void>; + /** + * `settings` is optional and asymmetric with `addServer`'s: **omitting** it + * tells the backend to preserve the entry's existing settings node, so a + * config-only save cannot silently wipe persisted headers / metadata / OAuth + * credentials. Pass a full settings object to replace it. + */ updateServer: ( originalId: string, newId: string, config: MCPServerConfig, + settings?: InspectorServerSettings, ) => Promise<void>; /** * Patch only the `settings` node on an existing server entry, leaving the @@ -202,11 +219,17 @@ export function useServers(opts: UseServersOptions): UseServersResult { }, [base, authToken, doFetch, refreshInternal]); const addServer = useCallback( - async (id: string, config: MCPServerConfig): Promise<void> => { + async ( + id: string, + config: MCPServerConfig, + settings?: InspectorServerSettings, + ): Promise<void> => { const res = await doFetch(`${base}/api/servers`, { method: "POST", headers: buildHeaders(authToken, true), - body: JSON.stringify({ id, config }), + // `settings` is omitted from the body when undefined (JSON.stringify + // drops undefined values), which the route reads as "no settings node". + body: JSON.stringify({ id, config, settings }), }); if (!res.ok) { throw new Error(await readErrorMessage(res)); @@ -235,18 +258,21 @@ export function useServers(opts: UseServersOptions): UseServersResult { originalId: string, newId: string, config: MCPServerConfig, + settings?: InspectorServerSettings, ): Promise<void> => { - // `settings` is intentionally omitted from the body. The backend route - // treats omission as "preserve the existing settings node on disk", so - // a config-only save (e.g. ServerConfigModal) cannot silently wipe - // persisted headers / metadata / OAuth credentials. To explicitly - // clear settings, send `settings: null`. + // With `settings` undefined, `JSON.stringify` drops the key entirely and + // the backend route treats that omission as "preserve the existing + // settings node on disk" — so a config-only save cannot silently wipe + // persisted headers / metadata / OAuth credentials. A caller editing a + // field that lives on settings (e.g. ServerConfigModal's custom headers) + // passes the full settings object it wants written. To explicitly clear + // settings, send `settings: null`. const res = await doFetch( `${base}/api/servers/${encodeURIComponent(originalId)}`, { method: "PUT", headers: buildHeaders(authToken, true), - body: JSON.stringify({ id: newId, config }), + body: JSON.stringify({ id: newId, config, settings }), }, ); if (!res.ok) { From 519ecf49eee195d99a4a73cde6615f6606fc66fa Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 20:57:32 -0400 Subject: [PATCH 182/941] chore(lint): enable no-floating-promises in all five ESLint scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A floated promise is invisible at review time — the call reads like an awaited one minus four characters — and the unhandled rejection it produces surfaces in a different test, in a different file, as a stack pointing at SDK internals. In #1947 two un-held `callTool` calls made `npm run ci` unpassable and took a full investigation to attribute; the fix was two lines. `@typescript-eslint/no-floating-promises` catches the class outright, so enable it at `error` everywhere. The rule is type-aware and `tseslint.configs.recommended` is not, so each scope's config gains a parser project. The four clients name their own tsconfig projects (both of them — `src` and `__tests__`/`server` live in different ones). The root scope has no tsconfig at all, so add `tsconfig.lint.json`: a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and gating nothing. It sets `moduleResolution: bundler` deliberately — under NodeNext `core/`'s extensionless imports fail to resolve, every import degrades to `any`, and the rule silently stops seeing promises at all. Measured surface, and the fixes: - core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono invokes subscribers with a bare `subscriber()`, so a promise returned from the listener is floated one layer up; `closeAbortedStream` owns the rejection where it can actually be owned. - tui (5) — `handleDisconnect` had no catch of its own and its only caller is a key handler that cannot await, so a failing disconnect was a live unhandled rejection; it now surfaces the message the way `handleConnect` does. The rest are `void` at callers that genuinely cannot await, over callees that already own their failures. - web (92) — 83 are Storybook `expect(...)`, which is instrumented and returns a promise, now awaited (`scrollAreaStoryAssertions` is async for the same reason). Two tests were asserting against a provider call they never awaited. `open(url)` in both web servers now warns instead of escaping a synchronous callback. - cli, launcher — already clean. Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s to ~19s, inside `validate`. Narrowing the loaded projects is the lever if that needs reducing later; dropping the rule is not. Closes #1959 Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + AGENTS.md | 6 ++++ README.md | 2 ++ clients/cli/eslint.config.js | 18 ++++++++++ clients/launcher/eslint.config.js | 15 ++++++++ clients/tui/__tests__/App.test.tsx | 29 +++++++++++++++ clients/tui/__tests__/tsupConfig.test.ts | 4 ++- clients/tui/eslint.config.js | 15 ++++++++ clients/tui/src/App.tsx | 18 +++++++--- clients/tui/src/components/PromptsTab.tsx | 6 ++-- clients/tui/src/components/ResourcesTab.tsx | 4 ++- clients/web/eslint.config.js | 27 ++++++++++++++ clients/web/server/server.ts | 8 ++++- clients/web/server/vite-hono-plugin.ts | 7 +++- .../CategoryBadge/CategoryBadge.stories.tsx | 4 +-- .../EmbeddableScrollArea.stories.tsx | 4 +-- .../ExpandToggle/ExpandToggle.stories.tsx | 6 ++-- .../McpErrorBadge/McpErrorBadge.stories.tsx | 8 ++--- .../MessageDirectionBadge.stories.tsx | 4 +-- .../MethodBadge/MethodBadge.stories.tsx | 4 +-- .../MonitoringToggle.stories.tsx | 4 +-- .../elements/PinToggle/PinToggle.stories.tsx | 8 +++-- .../ReplayButton/ReplayButton.stories.tsx | 4 ++- .../ResizeHandle/ResizeHandle.stories.tsx | 2 +- .../ScreenStage/ScreenStage.stories.tsx | 8 ++--- .../ConnectionInfoContent.stories.tsx | 10 +++--- .../LogStreamPanel/LogStreamPanel.stories.tsx | 2 +- .../MessageDirectionFilter.stories.tsx | 4 +-- .../MonitoringControls.stories.tsx | 4 +-- .../MonitoringScreen.stories.tsx | 4 +-- .../NetworkStreamPanel.stories.tsx | 2 +- .../OutputValidationModal.stories.tsx | 6 ++-- .../PendingClientRequestModal.stories.tsx | 14 ++++---- .../ProtocolListPanel.stories.tsx | 2 +- .../ResourceControls.stories.tsx | 18 +++++----- .../ResourceLink/ResourceLink.stories.tsx | 8 +++-- .../StructuredOutputPanel.stories.tsx | 8 ++--- .../UrlElicitationErrorModal.stories.tsx | 6 ++-- .../ResourcesScreen.stories.tsx | 26 ++++++++------ .../ServerListScreen.stories.tsx | 4 +-- .../InspectorView/InspectorView.stories.tsx | 10 +++--- .../src/test/PreviewAnnotations.stories.tsx | 18 +++++----- .../core/auth/ema/transportProvider.test.ts | 4 +-- .../src/test/core/mcp/oauthManager.test.ts | 4 +-- .../web/src/test/scrollAreaStoryAssertions.ts | 10 +++--- core/mcp/remote/node/server.ts | 20 +++++++++-- eslint.config.js | 36 +++++++++++++++++++ tsconfig.lint.json | 34 ++++++++++++++++++ 48 files changed, 359 insertions(+), 111 deletions(-) create mode 100644 tsconfig.lint.json diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 32122205..63b3edef 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -11,6 +11,7 @@ The Inspector ships as one package with three clients (**Web**, **CLI**, **TUI** - **Avoid double casts (`as unknown as T`).** They erase all type safety and usually mean the real type is being worked around. Prefer a type guard, a narrower single cast, or fixing the underlying type. If genuinely unavoidable (a documented gap in a third-party type, or bridging structurally-identical shapes TS can't relate), it **must** carry an inline comment justifying why it's safe and why nothing better exists. An unjustified `as unknown as` is not acceptable in review. - Prefer inference, type guards, and precise annotations over assertions. - An `_` prefix is the intentionally-unused marker (`argsIgnorePattern` / `varsIgnorePattern` / `caughtErrorsIgnorePattern`). +- **Never float a promise.** `@typescript-eslint/no-floating-promises` is `error` in all five ESLint scopes, so every promise must be awaited, `.catch(…)`-terminated, returned, or explicitly discarded with `void`. Prefer **holding and settling** it — a floated rejection surfaces in an unrelated file as an SDK-internal stack, which is how two un-held `callTool` calls failed the whole `npm run ci` chain (#1947). Reach for `void` only when the callee already owns its failures (it ends in a `catch`) or the caller genuinely cannot await (a sync effect body, an Ink key handler), and say which in a comment. In Storybook play functions, `expect(...)` from `storybook/test` is instrumented and returns a promise — it must be awaited. ## React and UI (web client) diff --git a/AGENTS.md b/AGENTS.md index d07e84f4..93d0eaac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -719,6 +719,12 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - Use type guards and type assertions to handle potential type mismatches and ensure type safety - Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code - Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices +- **NEVER leave a promise floating.** `@typescript-eslint/no-floating-promises` is enabled at `error` in **all five** ESLint scopes — `clients/{web,cli,tui,launcher}` and the root `core/` + shared gate (#1959). Every promise must be awaited, returned, terminated with `.catch(…)`, or explicitly discarded with the `void` operator. + - **The class it catches is invisible at review time.** A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held `client.callTool(...)` promises made `npm run ci` unpassable in #1947: `disconnect()` rejected them with `Connection closed`, the unhandled rejection failed the whole vitest run, and the chain aborted at `coverage`, silently skipping `verify:build-gate`, `smoke`, and Storybook. Attributing it took a full investigation; the fix was two lines. + - **Prefer holding and settling the promise.** `void` is an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in a `catch` that surfaces the message) or the caller genuinely cannot await — a synchronous `useEffect` body, an Ink `useInput` key handler, a Hono `stream.onAbort` listener. Say **which** of those it is in a one-line comment; an unexplained `void` is a review finding. Where the callee does *not* own its failures, give it a `catch` rather than voiding the call (see `handleDisconnect` in `clients/tui/src/App.tsx`), or terminate with `.catch(…)` at the call site (see `open(url)` in `clients/web/server/{server,vite-hono-plugin}.ts`). + - **In Storybook play functions, `expect(...)` from `storybook/test` returns a promise.** Storybook instruments it so the interactions panel can trace each assertion, so every `expect` in a play function is awaited — as is any shared helper that wraps one (`src/test/scrollAreaStoryAssertions.ts` is `async` for this reason). + - **The rule is type-aware, so each scope's ESLint config carries a parser project.** The four clients point at their own tsconfig projects (both of them, since `src` and `__tests__`/`server` live in different ones); the root config points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type *checking* for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **When adding a first-party TS location to the root lint scope, add it to `tsconfig.lint.json`'s `include` too**, or the parser fails it outright with "was not found in any of the provided project(s)". Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately. + - Type-aware linting costs real time: `clients/web`'s `eslint .` went from ~8s to ~19s, and `lint` runs inside `validate`, the fast inner-loop check. That is the price of the guarantee; if it needs reducing later, narrowing the projects each scope loads is the lever, not dropping the rule. ## Web source layout: `src/lib` vs `src/utils` diff --git a/README.md b/README.md index cb57dd35..1c81b9cd 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,8 @@ Each client self-validates from its own folder; the root scripts chain them. The Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. +**Linting is type-aware.** All five ESLint scopes (`clients/{web,cli,tui,launcher}` plus the root `core/` + shared gate) enable `@typescript-eslint/no-floating-promises` at `error`, so a promise that is neither awaited, returned, `.catch(…)`-terminated, nor explicitly discarded with `void` fails `lint` — and therefore `validate` ([#1959](https://github.com/modelcontextprotocol/inspector/issues/1959)). The rule needs type information, so each scope's config names a parser project; the root scope's is **`tsconfig.lint.json`**, a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, which have no tsconfig of their own. It emits nothing and changes no typecheck — but a new first-party TS location added to the root lint scope must be added to its `include`. See **TypeScript instructions** in [`AGENTS.md`](./AGENTS.md) for when `void` is acceptable. + For the full testing rules — the ≥90% per-file gate, where test files live, the unit vs. integration vs. storybook projects, and the `v8 ignore` policy — see [`AGENTS.md`](./AGENTS.md). ## Publishing diff --git a/clients/cli/eslint.config.js b/clients/cli/eslint.config.js index 9407f997..699b202f 100644 --- a/clients/cli/eslint.config.js +++ b/clients/cli/eslint.config.js @@ -16,4 +16,22 @@ export default defineConfig([ globals: globals.node, }, }, + { + // Type-aware pass for `no-floating-promises` (#1959). The rule needs type + // information, which `tseslint.configs.recommended` does not provide, and + // the parser needs a project that literally contains the linted file — so + // both of this client's tsconfig projects are listed, exactly as + // `npm run typecheck` runs them (`src` is in the first, `__tests__` only + // in the second). + files: ["**/*.ts"], + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.test.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, ]); diff --git a/clients/launcher/eslint.config.js b/clients/launcher/eslint.config.js index 535689a1..b13efaba 100644 --- a/clients/launcher/eslint.config.js +++ b/clients/launcher/eslint.config.js @@ -16,4 +16,19 @@ export default defineConfig([ globals: globals.node, }, }, + { + // Type-aware pass for `no-floating-promises` (#1959) — see the CLI config + // for the reasoning. Both tsconfig projects are listed because `src` and + // `__tests__` live in different ones. + files: ["**/*.{ts,mts}"], + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.test.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, ]); diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index d70b9c27..e4879b02 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -661,6 +661,35 @@ describe("App (foundation)", () => { expect(h.disconnect).toHaveBeenCalled(); }); + it("surfaces a disconnect failure instead of floating the rejection", async () => { + // 'd' is a key handler, so it cannot await handleDisconnect — the handler + // has to own the failure itself or it escapes as an unhandled rejection + // and fails the whole run from somewhere else (#1959). + // There is nothing to assert on screen — the message only renders once the + // client reports `status: "error"`, which a rejected disconnect does not + // do. The assertion is the run itself: vitest fails on an unhandled + // rejection, so this test passing is the proof the catch owns it. + h.ctrl.status = "connected"; + h.disconnect.mockRejectedValue(new Error("discfail")); + const { stdin } = await mount(oneStdio()); + stdin.write("d"); + await tick(); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + it("owns a non-Error disconnect rejection too", async () => { + // The catch stringifies a non-Error rejection rather than reading + // `.message` off it; a throw here would escape the same way. + h.ctrl.status = "connected"; + h.disconnect.mockRejectedValue("plainstring"); + const { stdin } = await mount(oneStdio()); + stdin.write("d"); + await tick(); + await tick(); + expect(h.disconnect).toHaveBeenCalled(); + }); + it("switches tabs via accelerator keys", async () => { const r = await mount(stdioServer()); await press(r, ["t"]); // tools tab (server is auto-selected) diff --git a/clients/tui/__tests__/tsupConfig.test.ts b/clients/tui/__tests__/tsupConfig.test.ts index a9bfc35f..8c61e9e9 100644 --- a/clients/tui/__tests__/tsupConfig.test.ts +++ b/clients/tui/__tests__/tsupConfig.test.ts @@ -207,7 +207,9 @@ describe("tui tsup config", () => { // the double cast is bridging a real structural gap, not hiding a mismatch. // A hook the patch called but the stub lacks fails as undefined here rather // than passing silently. - inkFormLabelPatch.setup( + // esbuild types `setup` as `void | Promise<void>`; await it so the + // assertions below can't run before an async setup has registered. + await inkFormLabelPatch.setup( build as unknown as Parameters<typeof inkFormLabelPatch.setup>[0], ); diff --git a/clients/tui/eslint.config.js b/clients/tui/eslint.config.js index b4f8a251..d37c6b7d 100644 --- a/clients/tui/eslint.config.js +++ b/clients/tui/eslint.config.js @@ -30,4 +30,19 @@ export default defineConfig([ "react-hooks/exhaustive-deps": "warn", }, }, + { + // Type-aware pass for `no-floating-promises` (#1959) — see the CLI config + // for the reasoning. Both tsconfig projects are listed because `src` and + // `__tests__` live in different ones. + files: ["**/*.{ts,tsx}"], + languageOptions: { + parserOptions: { + project: ["./tsconfig.json", "./tsconfig.test.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, ]); diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 62c9e743..3f0f931d 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -887,8 +887,15 @@ function App({ // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; - await disconnectInspector(); - // InspectorClient will update status automatically, and data is preserved + try { + await disconnectInspector(); + // InspectorClient will update status automatically, and data is preserved + } catch (err) { + // Nothing above this catches: the only caller is the key handler, which + // cannot await. Surface the failure the same way handleConnect does + // rather than letting it escape as an unhandled rejection. + setConnectError(err instanceof Error ? err.message : String(err)); + } }, [selectedServer, disconnectInspector]); const handleClearOAuth = useCallback(async () => { @@ -1428,12 +1435,15 @@ function App({ input.toLowerCase() === "c" && (inspectorStatus === "disconnected" || inspectorStatus === "error") ) { - handleConnect(); + // Both handlers own their failures internally (each ends in a catch + // that surfaces the message), so there is nothing left for a key + // handler — which cannot await — to do with the promise. + void handleConnect(); } else if ( input.toLowerCase() === "d" && (inspectorStatus === "connected" || inspectorStatus === "connecting") ) { - handleDisconnect(); + void handleDisconnect(); } } }); diff --git a/clients/tui/src/components/PromptsTab.tsx b/clients/tui/src/components/PromptsTab.tsx index 4f90c1a7..46db93b5 100644 --- a/clients/tui/src/components/PromptsTab.tsx +++ b/clients/tui/src/components/PromptsTab.tsx @@ -53,8 +53,10 @@ export function PromptsTab({ if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) { onFetchPrompt(selectedPrompt); } else { - // No arguments, fetch directly - (async () => { + // No arguments, fetch directly. The IIFE catches everything it can + // throw, so there is no rejection for this key handler — which + // cannot await — to own. + void (async () => { try { const invocation = await inspectorClient.getPrompt( selectedPrompt.name, diff --git a/clients/tui/src/components/ResourcesTab.tsx b/clients/tui/src/components/ResourcesTab.tsx index 86ba91ac..4d0fa05d 100644 --- a/clients/tui/src/components/ResourcesTab.tsx +++ b/clients/tui/src/components/ResourcesTab.tsx @@ -186,7 +186,9 @@ export function ResourcesTab({ } }; - fetchContent(); + // An effect body is synchronous, and fetchContent already catches + // everything it can throw, so there is no rejection left to own. + void fetchContent(); }, [shouldFetchResource, inspectorClient, onAuthRecoveryRequired]); const listWidth = Math.floor(width * 0.4); diff --git a/clients/web/eslint.config.js b/clients/web/eslint.config.js index 86602ed3..21726da5 100644 --- a/clients/web/eslint.config.js +++ b/clients/web/eslint.config.js @@ -31,5 +31,32 @@ export default defineConfig([ "react-refresh/only-export-components": "off", }, }, + { + // Type-aware pass for `no-floating-promises` (#1959). The rule needs type + // information, which `tseslint.configs.recommended` does not provide, and + // the parser needs a project that literally contains the linted file — so + // all four leaf projects are listed (`tsconfig.json` itself is a + // solution file with `files: []`, so it contains nothing). + // + // `.d.ts` is excluded: `vitest.shims.d.ts` is pulled in through a `types` + // reference rather than an `include`, so no project owns it, and a + // declaration file cannot float a promise anyway. + files: ["**/*.{ts,tsx}"], + ignores: ["**/*.d.ts"], + languageOptions: { + parserOptions: { + project: [ + "./tsconfig.app.json", + "./tsconfig.node.json", + "./tsconfig.storybook.json", + "./tsconfig.test.json", + ], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-floating-promises": "error", + }, + }, ...storybook.configs["flat/recommended"], ]); diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 9f6ff134..9d864ce2 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -125,7 +125,13 @@ export async function startHonoServer( sandboxUrl ?? undefined, ); if (config.autoOpen) { - open(url); + // A headless or otherwise browser-less host rejects here. The server + // is already listening and the banner above printed the URL, so warn + // and carry on rather than letting the rejection escape this + // synchronous listen callback and take the process down. + open(url).catch((err: unknown) => { + console.warn("Could not open a browser automatically:", err); + }); } }, ); diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 1331930c..f07b1af6 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -114,7 +114,12 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { ); if (config.autoOpen) { - open(url); + // Same as the prod server: a browser-less host rejects, the dev + // server is already up, and the banner above printed the URL — so + // warn rather than let the rejection escape this sync helper. + open(url).catch((err: unknown) => { + console.warn("Could not open a browser automatically:", err); + }); } }; diff --git a/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx index f9118c49..efbeab28 100644 --- a/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx +++ b/clients/web/src/components/elements/CategoryBadge/CategoryBadge.stories.tsx @@ -14,7 +14,7 @@ export const Transport: Story = { args: { category: "transport" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("transport")).toBeInTheDocument(); + await expect(canvas.getByText("transport")).toBeInTheDocument(); }, }; @@ -22,6 +22,6 @@ export const Auth: Story = { args: { category: "auth" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("auth")).toBeInTheDocument(); + await expect(canvas.getByText("auth")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx index e85f9b65..87da3cdb 100644 --- a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx @@ -24,7 +24,7 @@ export const FullSize: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Row 1")).toBeInTheDocument(); + await expect(canvas.getByText("Row 1")).toBeInTheDocument(); }, }; @@ -38,6 +38,6 @@ export const Embedded: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Row 1")).toBeInTheDocument(); + await expect(canvas.getByText("Row 1")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx index df4054c8..90369ab1 100644 --- a/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx +++ b/clients/web/src/components/elements/ExpandToggle/ExpandToggle.stories.tsx @@ -15,7 +15,9 @@ export const Collapsed: Story = { args: { expanded: false }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Expand" }), + ).toBeInTheDocument(); }, }; @@ -23,7 +25,7 @@ export const Expanded: Story = { args: { expanded: true }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("button", { name: "Collapse" }), ).toBeInTheDocument(); }, diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx index 4616b4fa..c189d654 100644 --- a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx @@ -14,7 +14,7 @@ export const HeaderMismatch: Story = { args: { code: -32020, name: "HeaderMismatch" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument(); + await expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument(); }, }; @@ -22,7 +22,7 @@ export const MissingCapability: Story = { args: { code: -32021, name: "MissingRequiredClientCapability" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByText("-32021 MissingRequiredClientCapability"), ).toBeInTheDocument(); }, @@ -32,7 +32,7 @@ export const UnsupportedVersion: Story = { args: { code: -32022, name: "UnsupportedProtocolVersion" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByText("-32022 UnsupportedProtocolVersion"), ).toBeInTheDocument(); }, @@ -47,6 +47,6 @@ export const MethodNotFound: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument(); + await expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx index a4aa55e0..c99b8a12 100644 --- a/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx +++ b/clients/web/src/components/elements/MessageDirectionBadge/MessageDirectionBadge.stories.tsx @@ -14,7 +14,7 @@ export const Outgoing: Story = { args: { direction: "outgoing" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("client → server")).toBeInTheDocument(); + await expect(canvas.getByText("client → server")).toBeInTheDocument(); }, }; @@ -22,6 +22,6 @@ export const Incoming: Story = { args: { direction: "incoming" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("server → client")).toBeInTheDocument(); + await expect(canvas.getByText("server → client")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx b/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx index 91e3abb5..312d249a 100644 --- a/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx +++ b/clients/web/src/components/elements/MethodBadge/MethodBadge.stories.tsx @@ -14,7 +14,7 @@ export const ToolsList: Story = { args: { method: "tools/list" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("tools/list")).toBeInTheDocument(); + await expect(canvas.getByText("tools/list")).toBeInTheDocument(); }, }; @@ -22,6 +22,6 @@ export const TasksList: Story = { args: { method: "tasks/list" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("tasks/list")).toBeInTheDocument(); + await expect(canvas.getByText("tasks/list")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx index 9541812e..109a47d8 100644 --- a/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx +++ b/clients/web/src/components/elements/MonitoringToggle/MonitoringToggle.stories.tsx @@ -15,7 +15,7 @@ export const Closed: Story = { args: { open: false }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("button", { name: "Open monitoring sidebar" }), ).toBeInTheDocument(); }, @@ -25,7 +25,7 @@ export const Open: Story = { args: { open: true }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("button", { name: "Close monitoring sidebar" }), ).toBeInTheDocument(); }, diff --git a/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx b/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx index d259eb84..a15900d4 100644 --- a/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx +++ b/clients/web/src/components/elements/PinToggle/PinToggle.stories.tsx @@ -15,7 +15,9 @@ export const Unpinned: Story = { args: { pinned: false }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Pin" }), + ).toBeInTheDocument(); }, }; @@ -23,6 +25,8 @@ export const Pinned: Story = { args: { pinned: true }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("button", { name: "Unpin" })).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Unpin" }), + ).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx index 6391f2b4..a7bd9b17 100644 --- a/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx @@ -14,6 +14,8 @@ type Story = StoryObj<typeof ReplayButton>; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Replay" }), + ).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx index 4fb1c122..7b8072dc 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx @@ -44,7 +44,7 @@ export const Default: Story = { render: () => <ResizeDemo />, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("separator", { name: "Resize demo panel" }), ).toBeInTheDocument(); }, diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.stories.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.stories.tsx index ab215303..c7af29bc 100644 --- a/clients/web/src/components/elements/ScreenStage/ScreenStage.stories.tsx +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.stories.tsx @@ -48,7 +48,7 @@ export const FillsHostWidth: Story = { // sharper discriminator: a row flex leaves the layer full-width while // collapsing the child to the single "x"). const layer = child.parentElement as HTMLElement; - expect(child.getBoundingClientRect().width).toBeCloseTo( + await expect(child.getBoundingClientRect().width).toBeCloseTo( layer.getBoundingClientRect().width, 0, ); @@ -77,11 +77,11 @@ export const FillVariant: Story = { // Width still stretches onto the child (relative to the layer, as above); // the `fill` height comes from `bottom: 0` making the layer span the host. const layer = child.parentElement as HTMLElement; - expect(child.getBoundingClientRect().width).toBeCloseTo( + await expect(child.getBoundingClientRect().width).toBeCloseTo( layer.getBoundingClientRect().width, 0, ); - expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4); + await expect(layer.getBoundingClientRect().height).toBeGreaterThan(160 - 4); }, }; @@ -97,6 +97,6 @@ export const Inactive: Story = { ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument(); + await expect(canvas.queryByTestId("stage-child")).not.toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index c6f1e598..0b1d79fe 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -106,8 +106,10 @@ export const ServerInfoNotReported: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument(); - expect(canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL)).toHaveLength(2); + await expect(canvas.queryByText("my-catalog-name")).not.toBeInTheDocument(); + await expect( + canvas.getAllByText(SERVER_INFO_NOT_REPORTED_LABEL), + ).toHaveLength(2); }, }; @@ -187,11 +189,11 @@ export const WithOAuthIdToken: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("ID Token")).toBeInTheDocument(); + await expect(canvas.getByText("ID Token")).toBeInTheDocument(); // Only the ID token is a JWT here, so exactly one decode toggle is offered. await userEvent.click( canvas.getByRole("button", { name: "Decode JWT for ID Token" }), ); - expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument(); + await expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx index 111f54e6..a7545dc3 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.stories.tsx @@ -77,6 +77,6 @@ export const WithEntries: Story = { }, // List scrollbar reserves a gutter and stays hidden when idle (#1474). play: async ({ canvasElement }) => { - expectScrollbarGutterIdleHidden(canvasElement); + await expectScrollbarGutterIdleHidden(canvasElement); }, }; diff --git a/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx index 362b30bd..8296dd63 100644 --- a/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx +++ b/clients/web/src/components/groups/MessageDirectionFilter/MessageDirectionFilter.stories.tsx @@ -31,10 +31,10 @@ export const AllVisible: Story = { args: { visibleDirections: { client: true, server: true } }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("button", { name: "client → server" }), ).toBeInTheDocument(); - expect( + await expect( canvas.getByRole("button", { name: "Deselect All" }), ).toBeInTheDocument(); }, diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx index 3a3eb7ff..eca1065a 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -20,7 +20,7 @@ type Story = StoryObj<typeof MonitoringControls>; export const AllTabs: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked(); + await expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked(); }, }; @@ -28,6 +28,6 @@ export const StdioServer: Story = { args: { tabs: ["Logs", "Protocol"], value: "Protocol" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull(); + await expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull(); }, }; diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx index 56c1cca2..b91d5d3b 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -47,7 +47,7 @@ type Story = StoryObj<typeof MonitoringScreen>; export const Logs: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Log stream")).toBeInTheDocument(); + await expect(canvas.getByText("Log stream")).toBeInTheDocument(); }, }; @@ -55,6 +55,6 @@ export const Protocol: Story = { args: { value: "Protocol" }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Request history")).toBeInTheDocument(); + await expect(canvas.getByText("Request history")).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx index a4248d1e..e006e261 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx @@ -50,7 +50,7 @@ export const WithEntries: Story = { args: { entries: sample }, // List scrollbar reserves a gutter and stays hidden when idle (#1474). play: async ({ canvasElement }) => { - expectScrollbarGutterIdleHidden(canvasElement); + await expectScrollbarGutterIdleHidden(canvasElement); }, }; diff --git a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx index 0942c7dd..40544352 100644 --- a/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx +++ b/clients/web/src/components/groups/OutputValidationModal/OutputValidationModal.stories.tsx @@ -45,14 +45,14 @@ export const Default: Story = { // Mantine renders the modal in a portal at document.body. const body = within(canvasElement.ownerDocument.body); await body.findByText("Output schema validation"); - expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument(); + await expect(body.getByText(/open_pattern_editor/)).toBeInTheDocument(); const details = body.getByLabelText( "Validation details", ) as HTMLTextAreaElement; - expect(details.value).toContain( + await expect(details.value).toContain( "data/samples/0 must NOT have additional properties", ); - expect(details.readOnly).toBe(true); + await expect(details.readOnly).toBe(true); }, }; diff --git a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx index 5130e98c..ee81a687 100644 --- a/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx +++ b/clients/web/src/components/groups/PendingClientRequestModal/PendingClientRequestModal.stories.tsx @@ -122,11 +122,11 @@ export const Sampling: Story = { play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); await body.findByText("Sampling Request"); - expect( + await expect( body.getByText("The server is requesting an LLM completion."), ).toBeInTheDocument(); await userEvent.click(body.getByRole("button", { name: "Send Response" })); - expect(args.onSamplingRespond).toHaveBeenCalled(); + await expect(args.onSamplingRespond).toHaveBeenCalled(); }, }; @@ -144,8 +144,8 @@ export const SamplingInputRequired: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); await body.findByText("Sampling Request"); - expect(body.getByText("input_required")).toBeInTheDocument(); - expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument(); + await expect(body.getByText("input_required")).toBeInTheDocument(); + await expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument(); }, }; @@ -172,11 +172,11 @@ export const ElicitationForm: Story = { play: async ({ canvasElement, args }) => { const body = within(canvasElement.ownerDocument.body); await body.findByText("Elicitation Request"); - expect( + await expect( body.getByText(/Please provide your database connection details/), ).toBeInTheDocument(); await userEvent.click(body.getByRole("button", { name: "Submit" })); - expect(args.onElicitationRespond).toHaveBeenCalledWith({ + await expect(args.onElicitationRespond).toHaveBeenCalledWith({ action: "accept", content: {}, }); @@ -207,7 +207,7 @@ export const ElicitationUrl: Story = { play: async ({ canvasElement }) => { const body = within(canvasElement.ownerDocument.body); await body.findByText("Elicitation Request"); - expect( + await expect( body.getByText("https://example.com/authorize?token=abc123"), ).toBeInTheDocument(); }, diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx index 2e1dfe8d..861f23c2 100644 --- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx +++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.stories.tsx @@ -107,7 +107,7 @@ export const WithEntries: Story = { // bar never overlays the cards, and uses type="scroll" so it stays hidden // when idle rather than appearing on hover (#1474). play: async ({ canvasElement }) => { - expectScrollbarGutterIdleHidden(canvasElement); + await expectScrollbarGutterIdleHidden(canvasElement); }, }; diff --git a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx index 958ec6aa..e7cd4f90 100644 --- a/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx +++ b/clients/web/src/components/groups/ResourceControls/ResourceControls.stories.tsx @@ -86,8 +86,8 @@ export const Default: Story = { // chevron points down. play: async ({ canvasElement }) => { const transforms = chevronTransforms(canvasElement); - expect(transforms).toHaveLength(3); - for (const t of transforms) expect(t).toBe(ROTATED_DOWN); + await expect(transforms).toHaveLength(3); + for (const t of transforms) await expect(t).toBe(ROTATED_DOWN); }, }; @@ -122,9 +122,9 @@ export const Collapsed: Story = { }, play: async ({ canvasElement }) => { const transforms = chevronTransforms(canvasElement); - expect(transforms).toHaveLength(3); + await expect(transforms).toHaveLength(3); // Identity matrix = the right-pointing arrow's natural 0° (not rotated). - for (const t of transforms) expect(t).toBe(NOT_ROTATED); + for (const t of transforms) await expect(t).toBe(NOT_ROTATED); }, }; @@ -140,10 +140,10 @@ export const EmptySectionCollapsed: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText("Subscriptions (0)")).toBeInTheDocument(); + await expect(canvas.getByText("Subscriptions (0)")).toBeInTheDocument(); const transforms = chevronTransforms(canvasElement); const downCount = transforms.filter((t) => t === ROTATED_DOWN).length; - expect(downCount).toBe(2); // URIs + Templates down, Subscriptions right + await expect(downCount).toBe(2); // URIs + Templates down, Subscriptions right }, }; @@ -158,11 +158,11 @@ export const SubscriptionsUnsupported: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument(); + await expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument(); // Only the two remaining sections render, both open → both chevrons down. const transforms = chevronTransforms(canvasElement); - expect(transforms).toHaveLength(2); - for (const t of transforms) expect(t).toBe(ROTATED_DOWN); + await expect(transforms).toHaveLength(2); + for (const t of transforms) await expect(t).toBe(ROTATED_DOWN); }, }; diff --git a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx index 655a5e7a..ac6b6ca2 100644 --- a/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx +++ b/clients/web/src/components/groups/ResourceLink/ResourceLink.stories.tsx @@ -45,10 +45,12 @@ export const Static: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.getByText(URI)).toBeInTheDocument(); + await expect(canvas.getByText(URI)).toBeInTheDocument(); // Copy button is present; there's no expand control in the static card. - expect(canvas.getByRole("button", { name: "Copy" })).toBeInTheDocument(); - expect( + await expect( + canvas.getByRole("button", { name: "Copy" }), + ).toBeInTheDocument(); + await expect( canvas.queryByRole("button", { name: /^Expand resource/ }), ).not.toBeInTheDocument(); }, diff --git a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx index c66760f0..78f1ea8d 100644 --- a/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx +++ b/clients/web/src/components/groups/StructuredOutputPanel/StructuredOutputPanel.stories.tsx @@ -41,7 +41,7 @@ export const Nested: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect( + await expect( canvas.getByRole("heading", { name: "Structured Output" }), ).toBeInTheDocument(); // The section starts expanded, so the payload is visible without a click. @@ -78,9 +78,9 @@ export const Large: Story = { } // Capped: the visible height stays at the section's `mah`, well under the // payload's natural height… - expect(viewport.clientHeight).toBeLessThanOrEqual(400); + await expect(viewport.clientHeight).toBeLessThanOrEqual(400); // …and the overflow is reachable by scrolling rather than clipped away. - expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); + await expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); }, }; @@ -95,7 +95,7 @@ export const Collapsed: Story = { const toggle = canvas.getByRole("button", { name: "Expand structured output", }); - expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); await userEvent.click(toggle); await waitFor(() => expect( diff --git a/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx index 4a6c64e8..bba498b3 100644 --- a/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx +++ b/clients/web/src/components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal.stories.tsx @@ -47,10 +47,10 @@ export const Default: Story = { // Mantine renders the modal in a portal at document.body. const body = within(canvasElement.ownerDocument.body); await body.findByText("URL elicitation required"); - expect(body.getByText(/trigger-url-elicitation/)).toBeInTheDocument(); + await expect(body.getByText(/trigger-url-elicitation/)).toBeInTheDocument(); const details = body.getByLabelText("Error details") as HTMLTextAreaElement; - expect(details.value).toContain("-32042"); - expect(details.readOnly).toBe(true); + await expect(details.value).toContain("-32042"); + await expect(details.readOnly).toBe(true); }, }; diff --git a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx index b1de0328..ebcdeb05 100644 --- a/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx +++ b/clients/web/src/components/screens/ResourcesScreen/ResourcesScreen.stories.tsx @@ -235,7 +235,7 @@ export const SubscriptionsUnsupported: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument(); + await expect(canvas.queryByText(/Subscriptions/)).not.toBeInTheDocument(); }, }; @@ -256,7 +256,7 @@ export const ModernSubscriptionStream: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - expect(await canvas.findByText("Listening")).toBeInTheDocument(); + await expect(await canvas.findByText("Listening")).toBeInTheDocument(); }, }; @@ -281,18 +281,22 @@ export const ManyResources: Story = { const sidebar = sidebarCard.getBoundingClientRect(); const detail = detailCard.getBoundingClientRect(); // The sidebar matches the detail panel's height (same bottom baseline). - expect(Math.abs(sidebar.bottom - detail.bottom)).toBeLessThanOrEqual(1); - expect(Math.abs(sidebar.height - detail.height)).toBeLessThanOrEqual(1); + await expect(Math.abs(sidebar.bottom - detail.bottom)).toBeLessThanOrEqual( + 1, + ); + await expect(Math.abs(sidebar.height - detail.height)).toBeLessThanOrEqual( + 1, + ); // The section headers stay pinned (visible, in document order), so the // whole accordion doesn't scroll as one block. const controls = canvasElement.querySelectorAll( ".disclosure-sections .mantine-Accordion-control", ); - expect(controls).toHaveLength(3); + await expect(controls).toHaveLength(3); const tops = [...controls].map((c) => c.getBoundingClientRect().top); - expect(tops[0]).toBeLessThan(tops[1]); - expect(tops[1]).toBeLessThan(tops[2]); + await expect(tops[0]).toBeLessThan(tops[1]); + await expect(tops[1]).toBeLessThan(tops[2]); // The long URIs section scrolls within its own panel, with a thin styled // scrollbar matching the Mantine ScrollArea look (#1466). @@ -302,13 +306,15 @@ export const ManyResources: Story = { if (!(urisPanel instanceof HTMLElement)) { throw new Error("URIs panel not found"); } - expect(urisPanel.scrollHeight).toBeGreaterThan(urisPanel.clientHeight); + await expect(urisPanel.scrollHeight).toBeGreaterThan( + urisPanel.clientHeight, + ); const panelStyle = getComputedStyle(urisPanel); - expect(panelStyle.scrollbarWidth).toBe("thin"); + await expect(panelStyle.scrollbarWidth).toBe("thin"); // The themed translucent thumb resolves (the `--inspector-scrollbar-thumb` // token, an rgba value, vs. a transparent track). WebKit pseudo-element // styles aren't queryable, so `scrollbar-color` is the assertable proxy. - expect(panelStyle.scrollbarColor).toContain("rgba"); + await expect(panelStyle.scrollbarColor).toContain("rgba"); }, }; diff --git a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx index 89e1e691..3eca532b 100644 --- a/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx +++ b/clients/web/src/components/screens/ServerListScreen/ServerListScreen.stories.tsx @@ -83,7 +83,7 @@ export const MultipleServers: Story = { // The server grid scroll region reserves a scrollbar gutter and hides the // bar when idle, matching the Protocol/Network/Logging list panels (#1474). play: async ({ canvasElement }) => { - expectScrollbarGutterIdleHidden(canvasElement); + await expectScrollbarGutterIdleHidden(canvasElement); }, }; @@ -148,7 +148,7 @@ export const KeyboardReorder: Story = { await waitFor(() => expect(args.onReorder).toHaveBeenCalled()); // The first card swapped places with the second; the third is unmoved. - expect(args.onReorder).toHaveBeenCalledWith([ + await expect(args.onReorder).toHaveBeenCalledWith([ disconnectedStdioServer.id, connectedStdioServer.id, failedHttpServer.id, diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 5b218726..fd7f25ac 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -452,10 +452,10 @@ export const Default: Story = { if (!(main instanceof HTMLElement)) { throw new Error("AppShell main not found"); } - expect(getComputedStyle(main).overflowY).toBe("hidden"); + await expect(getComputedStyle(main).overflowY).toBe("hidden"); // Clamped to the viewport (within a pixel of rounding), so the shell can't // grow past it and push the page into scrolling. - expect(main.getBoundingClientRect().height).toBeLessThanOrEqual( + await expect(main.getBoundingClientRect().height).toBeLessThanOrEqual( window.innerHeight + 1, ); }, @@ -489,8 +489,8 @@ export const ManyServers: Story = { if (!(main instanceof HTMLElement)) { throw new Error("AppShell main not found"); } - expect(getComputedStyle(main).overflowY).toBe("hidden"); - expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual( + await expect(getComputedStyle(main).overflowY).toBe("hidden"); + await expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual( window.innerHeight + 1, ); // The overflow lives in the inner server-grid ScrollArea, which is actually @@ -499,7 +499,7 @@ export const ManyServers: Story = { if (!(viewport instanceof HTMLElement)) { throw new Error("server-grid scroll viewport not found"); } - expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); + await expect(viewport.scrollHeight).toBeGreaterThan(viewport.clientHeight); }, }; diff --git a/clients/web/src/test/PreviewAnnotations.stories.tsx b/clients/web/src/test/PreviewAnnotations.stories.tsx index bca4fb48..c05b0ae4 100644 --- a/clients/web/src/test/PreviewAnnotations.stories.tsx +++ b/clients/web/src/test/PreviewAnnotations.stories.tsx @@ -69,25 +69,27 @@ type Story = StoryObj<typeof Button>; export const ProjectAnnotationsApplied: Story = { play: async ({ canvasElement, parameters }) => { const canvas = within(canvasElement); - expect(canvas.getByRole("button", { name: "Themed" })).toBeInTheDocument(); + await expect( + canvas.getByRole("button", { name: "Themed" }), + ).toBeInTheDocument(); const root = getComputedStyle(document.documentElement); // `MantineProvider` (the `./preview` decorator) injects the theme's CSS // variables onto `:root`. No provider, no variables. - expect(root.getPropertyValue("--mantine-primary-color-filled").trim()).toBe( - expectedPrimaryColor(), - ); + await expect( + root.getPropertyValue("--mantine-primary-color-filled").trim(), + ).toBe(expectedPrimaryColor()); // `--inspector-brand-primary` is defined in `App.css`, which only reaches // the story through `./preview`'s stylesheet import — and it resolves // *through* the Mantine variable above, so this covers both layers. - expect(root.getPropertyValue("--inspector-brand-primary").trim()).toBe( - expectedPrimaryColor(), - ); + await expect( + root.getPropertyValue("--inspector-brand-primary").trim(), + ).toBe(expectedPrimaryColor()); // The preview `parameters` merged into the story context — the same channel // that carries `a11y: { test: "error" }` to the a11y addon. - expect(parameters.a11y).toMatchObject({ test: "error" }); + await expect(parameters.a11y).toMatchObject({ test: "error" }); }, }; diff --git a/clients/web/src/test/core/auth/ema/transportProvider.test.ts b/clients/web/src/test/core/auth/ema/transportProvider.test.ts index b6a3b32a..ff66653e 100644 --- a/clients/web/src/test/core/auth/ema/transportProvider.test.ts +++ b/clients/web/src/test/core/auth/ema/transportProvider.test.ts @@ -117,12 +117,12 @@ describe("EmaTransportOAuthProvider", () => { expect(await provider.clientInformation()).toEqual({ client_id: "abc" }); expect(await provider.codeVerifier()).toBe("verifier-xyz"); - provider.saveClientInformation({ client_id: "new" } as never); + await provider.saveClientInformation({ client_id: "new" } as never); expect(inner.saveClientInformation).toHaveBeenCalledWith({ client_id: "new", }); - provider.saveCodeVerifier("cv"); + await provider.saveCodeVerifier("cv"); expect(inner.saveCodeVerifier).toHaveBeenCalledWith("cv"); }); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 99af81bf..04d9b80f 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1588,7 +1588,7 @@ describe("OAuthManager", () => { }); const provider = await manager.createOAuthProviderForTransport(); - provider.redirectToAuthorization( + await provider.redirectToAuthorization( new URL("https://as.example.com/authorize?client_id=abc&state=xyz"), ); @@ -1610,7 +1610,7 @@ describe("OAuthManager", () => { }); const provider = await manager.createOAuthProviderForTransport(); - provider.redirectToAuthorization( + await provider.redirectToAuthorization( new URL("https://as.example.com/authorize?client_id=abc&state=xyz"), ); diff --git a/clients/web/src/test/scrollAreaStoryAssertions.ts b/clients/web/src/test/scrollAreaStoryAssertions.ts index cd3fe28e..b3de5bbf 100644 --- a/clients/web/src/test/scrollAreaStoryAssertions.ts +++ b/clients/web/src/test/scrollAreaStoryAssertions.ts @@ -6,22 +6,24 @@ import { expect } from "storybook/test"; * hidden when idle (`type="scroll"`, so it shows only while scrolling rather * than on hover). Shared by the Protocol/Network/Logging panel stories (#1474). */ -export function expectScrollbarGutterIdleHidden(canvasElement: HTMLElement) { +export async function expectScrollbarGutterIdleHidden( + canvasElement: HTMLElement, +) { const viewport = canvasElement.querySelector(".mantine-ScrollArea-viewport"); if (!(viewport instanceof HTMLElement)) { throw new Error("scroll viewport not found"); } // offsetScrollbars reserves a non-zero inline-end gutter for the scrollbar. const pad = parseFloat(getComputedStyle(viewport).paddingInlineEnd); - expect(pad).toBeGreaterThan(0); + await expect(pad).toBeGreaterThan(0); // type="scroll" → scrollbars are mounted but hidden until the user scrolls. // Assert at least one exists first so the loop isn't trivially-true if a // future change stops rendering the scrollbar element. const scrollbars = canvasElement.querySelectorAll( ".mantine-ScrollArea-scrollbar", ); - expect(scrollbars.length).toBeGreaterThan(0); + await expect(scrollbars.length).toBeGreaterThan(0); for (const bar of scrollbars) { - expect(bar.getAttribute("data-state")).toBe("hidden"); + await expect(bar.getAttribute("data-state")).toBe("hidden"); } } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index d0ee6ea1..0a2408f9 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -99,6 +99,22 @@ import { ZodError } from "zod"; */ const SSE_PRIMING_COMMENT = ":\n\n"; +/** + * Close an SSE stream from inside an `onAbort` listener, discarding the + * rejection. + * + * `close()` is async and the peer is, by definition, already gone here — a + * write that loses the race rejects with nothing left to recover. The listener + * itself cannot own the promise either: Hono's `abort()` invokes subscribers + * with a bare `subscriber()` (`hono/utils/stream`), so a promise *returned* + * from the listener is floated by Hono rather than awaited, turning an + * `async` listener into the same unhandled rejection one layer up. Swallowing + * it here is the only place the rejection has an owner. + */ +function closeAbortedStream(stream: { close(): Promise<void> }): void { + stream.close().catch(() => {}); +} + /** * Shape of the initial config returned by GET /api/config (defaults for client). */ @@ -854,7 +870,7 @@ export function createRemoteApp( stream.onAbort(() => { // Client disconnected - clear event consumer const shouldCleanup = session.clearEventConsumer(); - stream.close(); + closeAbortedStream(stream); // If transport is dead and no client connected, cleanup session if (shouldCleanup || session.isTransportDead()) { @@ -2460,7 +2476,7 @@ export function createRemoteApp( stream.onAbort(() => { serverEventSubscribers.delete(send); void maybeStopWatcher(); - stream.close(); + closeAbortedStream(stream); }); // Hono closes the stream the moment this callback returns, so hold the diff --git a/eslint.config.js b/eslint.config.js index dfa945d1..b133e612 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,6 +10,31 @@ import { defineConfig, globalIgnores } from "eslint/config"; // isomorphic TypeScript (browser-side OAuth + Node backends + shared runtime); // the shared surface is Node-only tooling/tests. Neither has JSX, so no React // plugin is needed. +// Type-aware linting for the two blocks below. `no-floating-promises` needs +// type information, which `tseslint.configs.recommended` (the non-type-aware +// set) does not provide — so each block adds a parser project (#1959). +// +// `tsconfig.lint.json` exists for this and nothing else: the root has no +// tsconfig of its own (`core/` is typechecked through `clients/web`'s +// `tsc -b`, `test-servers/src` through `clients/cli`'s test project), and a +// parser project must literally *contain* every file it is asked to lint. +const typeAware = { + languageOptions: { + parserOptions: { + project: ["./tsconfig.lint.json"], + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // The class this catches is invisible at review time: the call reads like + // an awaited one minus four characters, and the unhandled rejection it + // produces surfaces in a different test, in a different file, as a stack + // pointing at SDK internals — which is how #1947 came to fail the whole + // `npm run ci` chain from two un-held `callTool` promises. + "@typescript-eslint/no-floating-promises": "error", + }, +}; + const sharedRules = { // An `_`-prefix is the explicit "intentionally unused" marker — // interface-conformance params in fakes, destructuring-rest omissions, @@ -59,4 +84,15 @@ export default defineConfig([ }, rules: sharedRules, }, + { + // Type-aware pass over the same surface, minus `eslint.config.js` — it is + // JavaScript, so no tsconfig contains it and asking the parser for a + // project would fail it outright rather than lint it. + files: [ + "core/**/*.{ts,tsx}", + "test-servers/src/**/*.{ts,tsx,mts,cts}", + "vitest.shared.mts", + ], + ...typeAware, + }, ]); diff --git a/tsconfig.lint.json b/tsconfig.lint.json new file mode 100644 index 00000000..1a119f96 --- /dev/null +++ b/tsconfig.lint.json @@ -0,0 +1,34 @@ +{ + // Parser project for the ROOT eslint config's type-aware rules (#1959), and + // nothing else — it emits nothing and gates nothing. + // + // `@typescript-eslint/no-floating-promises` needs a program containing the + // file being linted, and the root scope (`core/**`, `test-servers/src/**`, + // `vitest.shared.mts`) has no tsconfig of its own: `core/` is typechecked + // through `clients/web`'s `tsc -b` and `test-servers/src` through + // `clients/cli`'s test project, neither of which is rooted here. Type + // *checking* stays where it is; this file only gives the parser a program. + // + // `moduleResolution: bundler` because `core/` is written for a bundler + // (extensionless relative imports) — under NodeNext those fail to resolve, + // every import degrades to `any`, and the rule silently stops seeing + // promises at all. + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["node"], + "typeRoots": ["./clients/web/node_modules/@types", "./node_modules/@types"] + }, + "include": [ + "core/**/*.ts", + "core/**/*.tsx", + "test-servers/src/**/*.ts", + "test-servers/src/**/*.mts", + "test-servers/src/**/*.cts", + "vitest.shared.mts" + ] +} From 935113d1e06211a9d2e6bd6b33269981581ac27d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:04:20 -0400 Subject: [PATCH 183/941] fix: encode per RFC 3986, and derive the TUI form from the shared parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-4 review on #2035 — again reported as "no new comments" while carrying three suppressed ones. All three reproduced. 1. The TUI form still named its fields from the SDK. `uriTemplateToForm` read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields ";id" and `{id:3}` yields "id:3". The form therefore submitted `{ ";id": "7" }` while the shared expander looked up `id`, found nothing, and dropped the expression — the value vanished silently. This is the finding that mattered most, because it falsified the claim this change is built on. Moving the expander into core only makes the clients agree if each client's FORM derives its names from the same parser: a form submits under the names it rendered. The TUI now reads `templateVariables`, and the SDK template is constructed only to validate, preserving the existing malformed-template diagnostic. Its `required` flag comes from `requiredGroups`, and only a variable that is the sole member of a non-omittable expression is marked: ink- form cannot express "any one of these", so marking every member of `{a,b}` required would refuse input the expander accepts. 2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the sub-delims !'()* bare, which RFC 6570 requires encoded for every operator except + and #. Fixing that settled a design question left open in the previous round. With two encoders, the SAME value encoded differently depending on whether its expression happened to carry a modifier, since only the modifier pushed it onto our path. So delegation is gone entirely: one expander, one set of rules. The SDK's `UriTemplate` is still constructed, but only to validate a template. 3. The "Any one of: a, b" hint could contradict the disabled submit button. A name can sit in a singleton required group AND a shared one (`x://{a}/{a,b}`), where the singleton demands that exact field. The hint is suppressed in that case. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 34 +++--- README.md | 21 ++-- .../tui/__tests__/uriTemplateToForm.test.ts | 33 +++++- clients/tui/src/utils/uriTemplateToForm.ts | 52 ++++++--- .../ResourceTemplatePanel.tsx | 13 ++- .../web/src/test/core/mcp/uriTemplate.test.ts | 33 ++++++ core/mcp/uriTemplate.ts | 106 +++++++++--------- 7 files changed, 190 insertions(+), 102 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3b5df783..ccb2935c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,24 +73,22 @@ v2/main/ │ │ # a nullable field entirely — #1928/#2015) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores -│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand -│ │ # shared by the web Resources form and -│ │ # readResourceFromTemplate (TUI + CLI), so the -│ │ # clients cannot drift on what a template means; -│ │ # delegates to the SDK's UriTemplate for what it -│ │ # gets right, and takes over a whole template -│ │ # containing any of the three shapes it does not: -│ │ # `{a,b}` (raw-joined, unencoded, prefix dropped), -│ │ # `{;id}` (operator absent from its list), and -│ │ # `{id:3}` (prefix modifier folded into the name), -│ │ # and `{+v}`/`{#v}` (encodeURI mangles reserved -│ │ # `[`/`]` and double-encodes pct-triplets). The `;` -│ │ # and `:N` cases would render form fields literally -│ │ # labelled `;id` / `id:3`. Requiredness is per -│ │ # EXPRESSION, not per variable — requiredGroups + -│ │ # hasRequiredValues: `{a,b}` with only `a` filled is -│ │ # expandable, and a name recurring across -│ │ # expressions needs each group checked — #1919; +│ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand. +│ │ # The ONE expander for every client — the web +│ │ # Resources form and readResourceFromTemplate +│ │ # (TUI + CLI) — and every client derives its FORM +│ │ # FIELDS from it too (clients/tui uriTemplateToForm), +│ │ # which is what makes the sharing real: a form +│ │ # submits under the names it rendered, so a mangled +│ │ # name silently drops the value at expansion. The +│ │ # SDK's UriTemplate is used only to VALIDATE; its +│ │ # expander is wrong for `{a,b}` (raw-joined), +│ │ # `{;id}` (operator missing), `{id:3}` (modifier in +│ │ # the name), `{+v}`/`{#v}` (encodeURI mangles `[`/`]` +│ │ # and double-encodes pct-triplets), and `{v}` +│ │ # (encodeURIComponent leaves `!'()*` bare). +│ │ # Requiredness is per EXPRESSION, not per variable — +│ │ # requiredGroups + hasRequiredValues — #1919; │ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; diff --git a/README.md b/README.md index af8917c5..d4b0e61e 100644 --- a/README.md +++ b/README.md @@ -249,20 +249,21 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r > The plain `foobar://events` resource is registered deliberately, not as filler. The SDK's `UriTemplate.match()` compiles `{?topic}` to a **required** `\?topic=([^&]+)`, so a template alone cannot serve the blank read — `match("foobar://events")` returns `null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves. -All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — so they cannot disagree about what a template means. It delegates to the SDK's `UriTemplate` for every expression the SDK handles correctly, and takes over any template containing one of the three shapes it does not (each measured against the pinned SDK, not inferred): +All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — and all three derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. -| Shape | SDK behavior | Correct | -| --- | --- | --- | -| `{a,b}` | `foo/bar,q` — raw-joined, unencoded, operator prefix dropped | `foo%2Fbar,q` | -| `{;id}` | `""` — the `;` operator is not in its list, so the variable parses as `;id` | `;id=7` | -| `{id:3}` | `""` — the prefix modifier is folded into the name, giving `id:3` | `abc` | -| `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) | `[::1]`, `%2F` | +The SDK's `UriTemplate` is still used, but only to *validate* a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: -The `;` and `:3` rows matter beyond the URI: a form has to *name* the variables it asks the user to fill, so on the SDK's parse it would render fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. Takeover is per **template**, not per expression, so the cross-expression `?`-to-`&` rewrite always sees every expression that actually emitted. +| Shape | SDK behavior | +| --- | --- | +| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | +| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` | +| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | +| `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) | +| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded | -Requiredness is likewise a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and the form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which a per-variable flag cannot express once a name recurs across expressions. +The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. -One consequence worth knowing when writing a test server: the SDK's **matcher** has the mirrored gaps (`partToRegExp` emits a single capture for `{a,b}` and knows no `;`), so an SDK-backed server cannot round-trip those templates whatever the client sends. Emitting a spec-correct URI is the half the client controls; the unit tests cover those shapes directly rather than through a showcase server. +Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). #### Advertised extensions diff --git a/clients/tui/__tests__/uriTemplateToForm.test.ts b/clients/tui/__tests__/uriTemplateToForm.test.ts index 1c522c65..9dc1a32c 100644 --- a/clients/tui/__tests__/uriTemplateToForm.test.ts +++ b/clients/tui/__tests__/uriTemplateToForm.test.ts @@ -11,7 +11,38 @@ describe("uriTemplateToForm", () => { expect(form.title).toBe("Read Resource: file"); const fields = form.sections[0]!.fields; expect(fields.map((f) => f.name)).toEqual(["path", "name"]); - expect(fields[0]).toMatchObject({ type: "string", required: false }); + // Simple `{path}` sits mid-URI, so omitting it would leave an empty path + // segment rather than a shorter URI -- it is mandatory, matching the web + // panel. Both clients read this from core's `requiredGroups`. + expect(fields[0]).toMatchObject({ type: "string", required: true }); + }); + + it("names fields as the expander looks them up, not as the SDK parses them", () => { + // The SDK reports these as ";id" and "id:3"; submitting under those keys + // would make the expander find nothing and drop the expression (#1919). + expect( + uriTemplateToForm("x://a{;id}", "matrix").sections[0]!.fields.map( + (f) => f.name, + ), + ).toEqual(["id"]); + expect( + uriTemplateToForm("x://a/{id:3}", "prefix").sections[0]!.fields.map( + (f) => f.name, + ), + ).toEqual(["id"]); + }); + + it("leaves a shared required group optional rather than demanding every name", () => { + // `{a,b}` is satisfied by either name, and ink-form cannot say "any one + // of"; marking both required would refuse input the expander accepts. + const fields = uriTemplateToForm("x://{a,b}", "pair").sections[0]!.fields; + expect(fields.map((f) => f.name)).toEqual(["a", "b"]); + expect(fields.every((f) => f.required === false)).toBe(true); + }); + + it("marks an omittable variable optional", () => { + const fields = uriTemplateToForm("x://a{?topic}", "q").sections[0]!.fields; + expect(fields[0]).toMatchObject({ name: "topic", required: false }); }); it("returns an empty Template Variables section for a static URI", () => { diff --git a/clients/tui/src/utils/uriTemplateToForm.ts b/clients/tui/src/utils/uriTemplateToForm.ts index c8a027e9..af580a98 100644 --- a/clients/tui/src/utils/uriTemplateToForm.ts +++ b/clients/tui/src/utils/uriTemplateToForm.ts @@ -4,32 +4,50 @@ import type { FormStructure, FormSection, FormField } from "ink-form"; import { UriTemplate } from "@modelcontextprotocol/client"; +import { + requiredGroups, + templateVariables, +} from "@inspector/core/mcp/uriTemplate.js"; /** - * Converts a URI Template to ink-form structure + * Converts a URI Template to ink-form structure. + * + * Fields come from core's `templateVariables`, the same parser + * `InspectorClient.readResourceFromTemplate` expands through, so the key this + * form submits is the key the expander looks up. Using the SDK's + * `variableNames` here instead is not merely untidy -- it mangles the name: + * `{;id}` yields `";id"` and `{id:3}` yields `"id:3"`, so the form would submit + * `{ ";id": "7" }` while the expander looks for `id`, silently dropping the + * value and the whole expression with it (#1919). + * + * The SDK template is still constructed, but only to validate: core's parser is + * deliberately lenient (an unclosed `{` becomes literal text), so this is what + * still surfaces a malformed template as an empty form plus a logged error. */ export function uriTemplateToForm( uriTemplate: string, templateName: string, ): FormStructure { - const fields: FormField[] = []; + let fields: FormField[] = []; try { - const template = new UriTemplate(uriTemplate); - /* v8 ignore next -- UriTemplate.variableNames is a getter that always - returns a string[]; the `|| []` fallback is an unreachable guard. */ - const variableNames = template.variableNames || []; - - for (const variableName of variableNames) { - const field: FormField = { - name: variableName, - label: variableName, - type: "string", - required: false, // URI template variables are typically optional - }; - - fields.push(field); - } + new UriTemplate(uriTemplate); + // Only a variable that is the *sole* member of a non-omittable expression + // is genuinely mandatory. RFC 6570 drops undefined names from a multi-name + // expression, so `{a,b}` needs only one of the two -- ink-form cannot + // express "any one of these", and marking both required would refuse input + // the expander accepts. + const mandatory = new Set( + requiredGroups(uriTemplate) + .filter((names) => names.length === 1) + .map(([name]) => name), + ); + fields = templateVariables(uriTemplate).map(({ name }) => ({ + name, + label: name, + type: "string", + required: mandatory.has(name), + })); } catch (error) { // If parsing fails, return empty form console.error("Failed to parse URI template:", error); diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index 7b39b4ad..9c104821 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -253,9 +253,18 @@ export function ResourceTemplatePanel({ // required multi-name expression no single field is mandatory either // -- any one of them satisfies it -- so say which, rather than // marking each one required and blocking valid input. - const sharedGroup = groups.find( - (names) => names.length > 1 && names.includes(varName), + // A name can sit in a singleton required group *and* a shared one + // (`x://{a}/{a,b}`). The singleton demands this exact field, so the + // "any one of" hint would contradict the disabled submit button -- + // suppress it and let the field read as plainly required. + const individuallyRequired = groups.some( + (names) => names.length === 1 && names[0] === varName, ); + const sharedGroup = individuallyRequired + ? undefined + : groups.find( + (names) => names.length > 1 && names.includes(varName), + ); const description = !required ? "Optional" : sharedGroup diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 4c84d807..f0384f90 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -432,3 +432,36 @@ describe("allow-reserved encoding under + and #", () => { ); }); }); + +describe("unreserved encoding under the non-reserved operators", () => { + // `encodeURIComponent` leaves the sub-delims !'()* bare, but RFC 6570 only + // allows *unreserved* characters through for these operators. + it.each([ + ["", "x://"], + [".", "x://a."], + ["/", "x://a/"], + ])("encodes !'()* under the %s operator", (operator, prefix) => { + const base = operator === "" ? "x://" : "x://a"; + expect( + expandUriTemplate(`${base}{${operator}v}`, { v: "a!b'c(d)e*f" }), + ).toBe(`${prefix}a%21b%27c%28d%29e%2Af`); + }); + + it("encodes them in a named (query) expression too", () => { + expect(expandUriTemplate("x://a{?v}", { v: "a!b" })).toBe("x://a?v=a%21b"); + }); + + it("encodes them in a matrix expression too", () => { + expect(expandUriTemplate("x://a{;v}", { v: "a!b" })).toBe("x://a;v=a%21b"); + }); + + it("leaves them alone under + and #, where reserved characters are allowed", () => { + expect(expandUriTemplate("x://{+v}", { v: "a!b'c(d)e*f" })).toBe( + "x://a!b'c(d)e*f", + ); + }); + + it("still leaves the unreserved set itself untouched", () => { + expect(expandUriTemplate("x://{v}", { v: "aZ0-._~" })).toBe("x://aZ0-._~"); + }); +}); diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 0a790cf0..248abd92 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -2,32 +2,32 @@ * RFC 6570 URI Template parsing and expansion, shared by every client (#1919). * * This lives in `core/` rather than in a client so the web Resources form, the - * TUI, and the CLI cannot disagree about what a template means: the form calls - * {@link templateVariables} / {@link expandUriTemplate} directly, and the TUI - * and CLI reach the same code through `InspectorClient.readResourceFromTemplate`. + * TUI, and the CLI cannot disagree about what a template means. Every one of + * them derives its form fields from {@link templateVariables} and expands + * through {@link expandUriTemplate} — the web panel directly, the TUI and CLI + * via `InspectorClient.readResourceFromTemplate`. * * ## Why this is not simply `new UriTemplate(t).expand(v)` * - * Expansion is delegated to the SDK's `UriTemplate` for every expression it - * handles correctly — which is the overwhelmingly common case, and keeping it - * there means we cannot drift from the SDK on the ordinary path. But its parser - * and expander are incomplete in three ways that a *form* makes visible, - * because a form has to name the variables it is asking the user to fill in. - * Each was measured against the pinned SDK, not inferred: + * The SDK's `UriTemplate` is still used, but only to *validate* a template — + * constructing it is what rejects an unclosed expression. Its expander is not, + * because it is incomplete in ways a form makes visible: a form has to *name* + * the variables it asks the user to fill in, so a parser that mangles a name + * produces a field nobody can use. Each of these was measured against the + * pinned SDK, not inferred: * - * | Shape | SDK `variableNames` | SDK expansion | Correct | - * | ------------ | ------------------- | --------------------------------- | -------------- | - * | `{a,b}` | `["a","b"]` | `foo/bar,q` — unencoded, no prefix| `foo%2Fbar,q` | - * | `{;id}` | `[";id"]` | `""` — operator unknown | `;id=7` | - * | `{id:3}` | `["id:3"]` | `""` — modifier folded into name | `abc` | + * | Shape | SDK behavior | + * | ---------------- | --------------------------------------------------------- | + * | `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | + * | `{;id}` | `;` is not in its operator list, so the variable is `;id` | + * | `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | + * | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` and double-encodes `%` | + * | `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare | * - * For the last two the damage is not just a wrong URI: the form would render - * fields literally labelled `;id` and `id:3`, which the user cannot fill in - * usefully. So this module parses varspecs properly and, **when a template - * contains any expression the SDK gets wrong, expands that whole template - * itself** in {@link expandParts} rather than splicing corrected fragments into - * a template the SDK then re-expands — splicing would leave the SDK's - * cross-expression `?`-to-`&` rewrite unaware of the fragments we resolved. + * An earlier revision delegated the shapes the SDK got right and took over only + * the rest. That split is gone: once the encoders themselves differed, the two + * paths would have encoded the *same value* differently depending on whether + * the expression happened to carry a modifier. One expander, one set of rules. */ import { UriTemplate } from "@modelcontextprotocol/client"; @@ -287,11 +287,36 @@ function encodeAllowReserved(value: string): string { .join(""); } +/** + * Percent-encodes everything outside RFC 3986's *unreserved* set, which is what + * every operator except `+` and `#` calls for. + * + * `encodeURIComponent` alone is not that set: it leaves `!`, `'`, `(`, `)` and + * `*` unescaped. Those are sub-delims, not unreserved, so RFC 6570 requires + * them encoded for simple, label, path, matrix and query expansion. They are + * substituted afterwards rather than hand-rolled, so `encodeURIComponent` still + * does the UTF-8 work for everything else. + */ +const FORCE_ENCODED: Record<string, string> = { + "!": "%21", + "'": "%27", + "(": "%28", + ")": "%29", + "*": "%2A", +}; + +function encodeUnreserved(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (char) => FORCE_ENCODED[char], + ); +} + /** Encodes one value for its operator: reserved characters survive `+` and `#`. */ function encodeValue(value: string, operator: string): string { return operator === "+" || operator === "#" ? encodeAllowReserved(value) - : encodeURIComponent(value); + : encodeUnreserved(value); } /** @@ -309,33 +334,6 @@ function renderValue(value: string, spec: VarSpec, operator: string): string { return encodeValue(truncated, operator); } -/** - * True for an expression the SDK would get wrong, and which this module must - * therefore expand itself. See the table in the module comment. - */ -function needsOwnExpansion(part: TemplatePart): boolean { - if (part.kind !== "expression") return false; - return ( - // Multi-name, non-query: the SDK raw-joins, skipping encoding and prefix. - (part.varspecs.length > 1 && - part.operator !== "?" && - part.operator !== "&") || - // The SDK has no `;` operator at all. - part.operator === ";" || - // The SDK encodes `+`/`#` values with `encodeURI`, which mangles reserved - // `[`/`]` and double-encodes existing pct-triplets — see - // `encodeAllowReserved`. Taken over even for a single name so both paths - // agree on what these operators mean. - part.operator === "+" || - part.operator === "#" || - // The SDK folds everything after a `:` into the variable name. Keyed on the - // raw source rather than on a parsed `maxLength` so a *malformed* modifier - // (`{id:}`, `{id:abc}`) is caught too: this module drops the modifier and - // looks up `id`, while the SDK would look up `id:` and find nothing. - part.source.includes(":") - ); -} - /** Expands one expression per RFC 6570. Returns "" if no name has a value. */ function expandExpression( part: TemplateExpression, @@ -426,11 +424,11 @@ export function expandUriTemplateStrict( values: Record<string, string>, ): string { const defined = definedValues(values); - const sdkTemplate = new UriTemplate(uriTemplate); - const parts = parseUriTemplate(uriTemplate); - return parts.some(needsOwnExpansion) - ? expandParts(parts, defined) - : sdkTemplate.expand(defined); + // Constructed purely to validate: this is what throws `Unclosed template + // expression`, and callers such as `readResourceFromTemplate` wrap that + // error. Its `expand` is deliberately not used — see the module comment. + new UriTemplate(uriTemplate); + return expandParts(parseUriTemplate(uriTemplate), defined); } /** From 05698647f8237614d80cfe01ffdadafceef39950 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:13:51 -0400 Subject: [PATCH 184/941] fix(auth): never apply endpoint overrides on the enterprise-managed leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overrides are per-server configuration for the *resource* server's authorization server, but the fetch wrapper that applies them is shared with the enterprise-managed (EMA) flow, whose IdP OIDC discovery runs through the same fetch and satisfies the metadata predicate. An authorization override could therefore redirect the IdP login to the resource AS, and a token override could send the IdP code or refresh token there. OAuthManager.getEndpointOverrides() now returns nothing when the server is enterprise-managed — the same boundary redirectToExternalAuthorization draws for the custom authorization parameters (#2018) — with a regression test, the note surfaced on both form fields, and the rule documented. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ServerSettingsForm.test.tsx | 28 +++++++++++++++++++ .../ServerSettingsForm/ServerSettingsForm.tsx | 11 ++++++-- .../src/test/core/mcp/oauthManager.test.ts | 23 +++++++++++++++ core/auth/endpointOverrides.ts | 8 +++++- core/mcp/oauthManager.ts | 10 +++++++ docs/mcp-server-configuration.md | 2 ++ 6 files changed, 79 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index ac26d2eb..09ded171 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -999,6 +999,34 @@ describe("ServerSettingsForm", () => { ); }); + it("says the endpoint overrides are unused under enterprise-managed auth", () => { + const { rerender } = renderWithMantine( + <ServerSettingsForm + {...baseHandlers} + settings={{ ...emptySettings, enterpriseManaged: true }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.getAllByText( + /Not applied while Enterprise-managed authorization is on/, + ), + ).toHaveLength(2); + + rerender( + <ServerSettingsForm + {...baseHandlers} + settings={{ ...emptySettings, enterpriseManaged: false }} + expandedSections={["oauth"]} + />, + ); + expect( + screen.queryByText( + /Not applied while Enterprise-managed authorization is on/, + ), + ).not.toBeInTheDocument(); + }); + it("flags an endpoint override that is not an absolute http(s) URL", () => { renderWithMantine( <ServerSettingsForm diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 887555c3..7c80dbab 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -486,6 +486,13 @@ export function ServerSettingsForm({ }; } + // #1906 — the overrides are suppressed under EMA for the same reason the + // custom authorization parameters are: that leg authorizes against the + // enterprise IdP, a different authorization server. + const endpointOverrideEmaNote = settings.enterpriseManaged + ? " Not applied while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server." + : ""; + const rejectedParamKeys = authorizationParams .map((p) => p.key.trim()) .filter((key) => isReservedAuthorizationParam(key)); @@ -868,7 +875,7 @@ export function ServerSettingsForm({ </Stack> <ClearableTextInput label="Authorization URL override" - description="Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead." + description={`Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead.${endpointOverrideEmaNote}`} placeholder="https://staging.auth.example.com/authorize" value={settings.oauthAuthorizationUrl ?? ""} error={oauthEndpointUrlError( @@ -895,7 +902,7 @@ export function ServerSettingsForm({ /> <ClearableTextInput label="Token URL override" - description="Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone." + description={`Leave blank to use the token_endpoint the authorization server's metadata advertises. Independent of the authorization URL — either can be overridden alone.${endpointOverrideEmaNote}`} placeholder="https://staging.auth.example.com/token" value={settings.oauthTokenUrl ?? ""} error={oauthEndpointUrlError(settings.oauthTokenUrl ?? "")} diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 61b1efe7..6047b8b0 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -137,6 +137,29 @@ describe("OAuthManager", () => { tokenUrl: "https://staging.test/token", }); }); + + // The EMA leg authorizes against the enterprise IdP — a different + // authorization server, whose OIDC discovery runs through the same fetch the + // override wrapper is installed on. Applying a resource-AS override there + // would redirect the IdP login (or the IdP code exchange) to the resource + // AS. Regression test for the review finding on PR #2037. + it("suppresses the overrides under enterprise-managed authorization", () => { + const manager = new OAuthManager(createMockParams()); + manager.setOAuthConfig({ + authorizationUrl: "https://staging.test/authorize", + tokenUrl: "https://staging.test/token", + }); + expect(manager.getEndpointOverrides()).toBeDefined(); + + manager.setOAuthConfig({ enterpriseManaged: true }); + expect(manager.getEndpointOverrides()).toBeUndefined(); + + manager.setOAuthConfig({ enterpriseManaged: false }); + expect(manager.getEndpointOverrides()).toEqual({ + authorizationUrl: "https://staging.test/authorize", + tokenUrl: "https://staging.test/token", + }); + }); }); describe("getServerUrl propagation", () => { diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 00528cad..7ae07155 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -22,7 +22,7 @@ * sees both is the `fetchFn` the SDK uses for discovery, so the override is * applied to the metadata document in flight. * - * Two consequences worth knowing: + * Three consequences worth knowing: * * - **A metadata document is required.** When discovery returns nothing, the SDK * falls back to `/authorize` and `/token` on the authorization server's origin @@ -30,6 +30,12 @@ * overriding "whatever urls the authorization server returns" — and a server * publishing no metadata is already on a path where the endpoints are not * being advertised in the first place. + * - **They do not apply to the enterprise-managed (EMA) leg.** That flow + * authorizes against the enterprise IdP — a different authorization server — + * and its OIDC discovery runs through this same fetch, so `OAuthManager` + * suppresses the overrides when the server is enterprise-managed. This mirrors + * `redirectToExternalAuthorization` skipping the custom authorization + * parameters (#2018). * - **The Network tab shows the patched document.** The wrapper is applied to the * base fetch, inside the request tracker, so what the tab renders is the * metadata as the flow consumed it. That is the useful reading: it explains why diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index b17b3d19..59846e2a 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -114,8 +114,18 @@ export class OAuthManager { * `setOAuthConfig` between requests takes effect without rebuilding the * client's fetch. `InspectorClient` reads this from inside its fetch wrapper. * (#1906) + * + * Returns nothing under enterprise-managed authorization, for the same reason + * `redirectToExternalAuthorization` skips the custom authorization parameters + * (#2018): the EMA leg authorizes against the enterprise IdP, a *different* + * authorization server, and its OIDC discovery runs through this same fetch + * (`emaFlow` → `idpOidc`). Rewriting that document would point the IdP login — + * or the IdP code/refresh-token exchange — at the resource server's + * authorization server, which is both wrong and a way to leak an IdP + * credential across an authorization-server boundary. */ getEndpointOverrides(): OAuthEndpointOverrides | undefined { + if (this.isEnterpriseManaged()) return undefined; const { authorizationUrl, tokenUrl } = this.oauthConfig; if (!authorizationUrl && !tokenUrl) return undefined; return { diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index fa3e9409..c317adfb 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -181,6 +181,8 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w `oauth.authorizationUrl` and `oauth.tokenUrl` override the `authorization_endpoint` and `token_endpoint` that authorization-server metadata discovery resolved. The Inspector deliberately has no such fields by default — it resolves both from the AS's metadata document, exactly as a real MCP host does — but a server under development often advertises its *production* authorization server while you want to hit staging. Set either (they are independent) to an absolute `http(s)` URL and it replaces what the metadata returned, for both the authorization request and the token request; leave blank to use discovery. A malformed value is flagged inline in the form and dropped with a warning at connect time rather than failing the connection. Edit them in Server Settings → Authorization ("Authorization URL override" / "Token URL override"). +They are **not applied under enterprise-managed authorization** (`oauth.enterpriseManaged`), for the same reason `oauth.authorizationParams` isn't: that flow authorizes against the enterprise IdP — a different authorization server, whose OIDC discovery would otherwise be rewritten too, pointing the IdP login (or the IdP code exchange) at the resource server's authorization server. + > Because the override is applied to the *discovered metadata document*, a server whose authorization server publishes no metadata at all is unaffected — there the SDK falls back to `/authorize` and `/token` on the AS origin, and there is nothing to override. A catalog carrying these fields: From 82ce02b516aee46a3b75173934aaef0b39b85297 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:14:33 -0400 Subject: [PATCH 185/941] fix(web): don't copy a cloned server's other settings (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #2038. `configModalTarget` is the *source* server in clone mode, so spreading its settings copied that server's OAuth client secret, metadata, roots and behavior flags onto a new entry the user had only given a URL and headers. Only an edit carries the other fields forward now — it has to, since `settings` replaces the node wholesale — while add and clone build from the empty settings shape. Also drops an unnecessary `as unknown as` in the new useServers test (`mcpServers` already holds `StoredMCPServer`, which types `headers`), and removes a `settings: null` instruction the parameter's type does not allow. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 19 ++++++++++++++----- .../src/test/core/react/useServers.test.tsx | 8 +++++--- core/react/useServers.ts | 4 ++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index e3d2557a..4c6598bf 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3927,11 +3927,20 @@ function App() { const onConfigSubmit = useCallback( async (id: string, config: MCPServerConfig, headers: KeyValuePair[]) => { // Headers live on the entry's `settings`, not on the transport config, - // so they're folded back in here (#1915). Send `settings` only when it - // would change something: with no headers either way there is nothing to - // write, and omitting the key is what makes the backend preserve the - // rest of the settings node. - const existing = configModalTarget?.settings; + // so they're folded back in here (#1915). + // + // Only an EDIT carries the target's other settings forward. `settings` + // replaces the whole node, so an edit must re-send the fields this modal + // doesn't expose (metadata, timeouts, OAuth credentials, roots) or they + // would be dropped. A CLONE must not: `configModalTarget` there is the + // *source* server, and spreading it would copy that server's OAuth + // client secret and every behavior flag onto a new entry the user only + // gave a URL and some headers. + const isEdit = configModal?.mode === "edit" && !!configModal.targetId; + const existing = isEdit ? configModalTarget?.settings : undefined; + // Send `settings` only when it would change something: with no headers + // on either side there is nothing to write, and omitting the key is what + // makes the backend preserve the node it already has. const settingsChanged = headers.length > 0 || (existing?.headers.length ?? 0) > 0; const settings = settingsChanged diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index 49b423f2..d66beb4c 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -154,9 +154,11 @@ describe("useServers", () => { { key: "Cookie", value: "branch=feature-x" }, ]); }); - const stored = readConfig(h.configPath).mcpServers - .remote as unknown as Record<string, unknown>; - expect(stored.headers).toEqual({ Cookie: "branch=feature-x" }); + // On disk headers are the flat `Record<string, string>` form, a direct key + // on the entry (post-#1358) rather than a nested settings node. + expect(readConfig(h.configPath).mcpServers.remote?.headers).toEqual({ + Cookie: "branch=feature-x", + }); }); it("updateServer replaces the settings node when one is supplied (#1915)", async () => { diff --git a/core/react/useServers.ts b/core/react/useServers.ts index 69935dd9..f206a0d5 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -265,8 +265,8 @@ export function useServers(opts: UseServersOptions): UseServersResult { // settings node on disk" — so a config-only save cannot silently wipe // persisted headers / metadata / OAuth credentials. A caller editing a // field that lives on settings (e.g. ServerConfigModal's custom headers) - // passes the full settings object it wants written. To explicitly clear - // settings, send `settings: null`. + // passes the full settings object it wants written, which replaces the + // node wholesale — so it must carry forward the fields it isn't editing. const res = await doFetch( `${base}/api/servers/${encodeURIComponent(originalId)}`, { From 5d8094357de7bd37841bc07a3e5576bae2c9ddba Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:16:34 -0400 Subject: [PATCH 186/941] fix(tui): surface a disconnect failure independently of connection status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch (Copilot, PR #2039): folding the rejected disconnect into `connectError` meant nobody ever saw it. InfoTab renders that value only when `serverState.status === "error"`, and a failed disconnect leaves the status "connected" — so the message existed in state and never on screen. Worse, it fed `connectError ?? inspectorLastError`, where a stale value would go on masking a later, real connection error. Give it its own `disconnectError` state and a header banner that renders regardless of status, cleared on the next connect or disconnect attempt and on a server switch, so it can neither linger nor be attributed to the wrong server. The tests now assert the rendered banner rather than noting that nothing is visible. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/tui/__tests__/App.test.tsx | 35 ++++++++++++++++++------------ clients/tui/src/App.tsx | 26 +++++++++++++++++++--- 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index e4879b02..8a5124f5 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -665,17 +665,14 @@ describe("App (foundation)", () => { // 'd' is a key handler, so it cannot await handleDisconnect — the handler // has to own the failure itself or it escapes as an unhandled rejection // and fails the whole run from somewhere else (#1959). - // There is nothing to assert on screen — the message only renders once the - // client reports `status: "error"`, which a rejected disconnect does not - // do. The assertion is the run itself: vitest fails on an unhandled - // rejection, so this test passing is the proof the catch owns it. + // The banner is deliberately independent of connection status: a rejected + // disconnect leaves the status "connected", so anything gated on + // `status === "error"` would never be seen. h.ctrl.status = "connected"; h.disconnect.mockRejectedValue(new Error("discfail")); - const { stdin } = await mount(oneStdio()); - stdin.write("d"); - await tick(); - await tick(); - expect(h.disconnect).toHaveBeenCalled(); + const r = await mount(oneStdio()); + r.stdin.write("d"); + await expectFrame(r, "Disconnect failed: discfail"); }); it("owns a non-Error disconnect rejection too", async () => { @@ -683,11 +680,21 @@ describe("App (foundation)", () => { // `.message` off it; a throw here would escape the same way. h.ctrl.status = "connected"; h.disconnect.mockRejectedValue("plainstring"); - const { stdin } = await mount(oneStdio()); - stdin.write("d"); - await tick(); - await tick(); - expect(h.disconnect).toHaveBeenCalled(); + const r = await mount(oneStdio()); + r.stdin.write("d"); + await expectFrame(r, "Disconnect failed: plainstring"); + }); + + it("clears a disconnect failure once a retry succeeds", async () => { + // A stale banner would keep reporting a failure the user has since fixed. + h.ctrl.status = "connected"; + h.disconnect.mockRejectedValueOnce(new Error("discfail")); + const r = await mount(oneStdio()); + r.stdin.write("d"); + await expectFrame(r, "Disconnect failed: discfail"); + r.stdin.write("d"); + await waitUntil(() => !(r.lastFrame() ?? "").includes("Disconnect failed")); + expect(r.lastFrame() ?? "").not.toContain("Disconnect failed"); }); it("switches tabs via accelerator keys", async () => { diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 3f0f931d..0c779ab7 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -170,6 +170,15 @@ function App({ pendingStepUpRef.current = pendingStepUp; }, [pendingStepUp]); const [connectError, setConnectError] = useState<string | null>(null); + // A failed disconnect is deliberately NOT folded into `connectError`. That + // one is only rendered by InfoTab when the status is "error", which a + // rejected disconnect leaves untouched (the status stays "connected"), so + // the message would never be seen — and it feeds + // `connectError ?? inspectorLastError`, where a stale value would go on + // masking a later, real connection error. This renders in the header + // regardless of status and is cleared on the next connect/disconnect + // attempt and on a server switch. + const [disconnectError, setDisconnectError] = useState<string | null>(null); const oauthInProgressRef = useRef(false); const callbackServerRef = useRef<OAuthCallbackServer | null>(null); const selectedServerRef = useRef<string | null>(null); @@ -434,6 +443,9 @@ function App({ useEffect(() => { setOauthStatus("idle"); setOauthMessage(null); + // The header banner is server-scoped, so a failure from the server we just + // left must not be read as this one's. + setDisconnectError(null); const stepUp = pendingStepUpRef.current; if (stepUp && selectedServer && stepUp.serverName !== selectedServer) { setPendingStepUp(null); @@ -757,6 +769,8 @@ function App({ if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { return; } + // A connect attempt supersedes whatever the last disconnect reported. + setDisconnectError(null); const finishConnect = async () => { await connectInspector(); @@ -887,14 +901,15 @@ function App({ // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; + // Clear first, so a retry that succeeds leaves no stale message behind. + setDisconnectError(null); try { await disconnectInspector(); // InspectorClient will update status automatically, and data is preserved } catch (err) { // Nothing above this catches: the only caller is the key handler, which - // cannot await. Surface the failure the same way handleConnect does - // rather than letting it escape as an unhandled rejection. - setConnectError(err instanceof Error ? err.message : String(err)); + // cannot await, so without this the rejection escapes unhandled. + setDisconnectError(err instanceof Error ? err.message : String(err)); } }, [selectedServer, disconnectInspector]); @@ -1627,6 +1642,11 @@ function App({ <Text color="red">OAuth: {oauthMessage}</Text> </Box> )} + {disconnectError && ( + <Box marginTop={1}> + <Text color="red">Disconnect failed: {disconnectError}</Text> + </Box> + )} </Box> </Box> From ac8a08e42f9efe24a6f234a363c223b93c72bfde Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:30:32 -0400 Subject: [PATCH 187/941] fix(auth): only rebuild the response when an override is actually applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the fetch wrapper: Reading the body with `response.text()` meant every successful JSON response was rebuilt while overrides were configured, even when it was not a metadata document. That dropped native properties a synthesized `Response` cannot carry (`url`, `redirected`, `type`) from responses the wrapper has no business altering, and threw outright on a body-less 204/205, whose status Fetch forbids a body on — so an unrelated transport response could fail. It now parses a clone and returns the caller's own response unless a document is actually patched. `new URL` also accepts embedded credentials while Fetch rejects a request URL carrying them, so `https://user:pass@host/token` passed both the form and the runtime and then failed deep in the OAuth exchange. `oauthEndpointUrlError` now rejects a username or password. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../test/core/auth/endpointOverrides.test.ts | 62 ++++++++++++++++--- core/auth/endpointOverrides.ts | 26 +++++--- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 7403ce97..0d9d214e 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -55,6 +55,17 @@ describe("oauthEndpointUrlError", () => { /not an http\(s\) URL/, ); }); + + // `new URL` accepts these, but Fetch rejects a request URL carrying them — + // so without this check the value passes validation and fails mid-flow. + it("rejects embedded credentials", () => { + expect( + oauthEndpointUrlError("https://user:pass@as.example.com/token"), + ).toMatch(/username or password/); + expect(oauthEndpointUrlError("https://user@as.example.com/token")).toMatch( + /username or password/, + ); + }); }); describe("normalizeOAuthEndpointOverrides", () => { @@ -90,6 +101,18 @@ describe("normalizeOAuthEndpointOverrides", () => { ); }); + it("drops a URL with embedded credentials", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect( + normalizeOAuthEndpointOverrides({ + tokenUrl: "https://user:pass@as.example.com/token", + }), + ).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("username or password"), + ); + }); + it("returns undefined when every configured value is malformed", () => { vi.spyOn(console, "warn").mockImplementation(() => {}); expect( @@ -236,24 +259,43 @@ describe("withOAuthEndpointOverrides", () => { await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); }); - it("preserves a JSON body that is not metadata, and stays readable", async () => { - const wrapped = withOAuthEndpointOverrides( - passThrough(jsonResponse({ access_token: "abc" })), - () => ({ tokenUrl: STAGING_TOKEN }), - ); + // Everything that is not a metadata document comes back as the caller's own + // Response — identity, not an equivalent copy — so native properties a + // synthesized Response cannot carry (`url`, `redirected`, `type`) survive. + it("returns the original response for a JSON body that is not metadata", async () => { + const original = jsonResponse({ access_token: "abc" }); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); const response = await wrapped("https://as.example.com/token"); + expect(response).toBe(original); await expect(response.json()).resolves.toEqual({ access_token: "abc" }); }); - it("hands back a readable response when the body is not valid JSON", async () => { - const wrapped = withOAuthEndpointOverrides( - passThrough(jsonResponse("not json at all")), - () => ({ tokenUrl: STAGING_TOKEN }), - ); + it("returns the original response when the body is not valid JSON", async () => { + const original = jsonResponse("not json at all"); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); const response = await wrapped("https://as.example.com/x"); + expect(response).toBe(original); await expect(response.text()).resolves.toBe("not json at all"); }); + // Fetch forbids a body on 204/205, so rebuilding one would throw. A JSON + // content-type on an empty response is unusual but legal, and it must not + // take down an unrelated transport request. + it("passes a body-less 204 through untouched", async () => { + const original = new Response(null, { + status: 204, + headers: { "content-type": "application/json" }, + }); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); + await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + }); + it("drops the stale content-length and content-encoding of a rewritten body", async () => { const body = JSON.stringify(METADATA); const wrapped = withOAuthEndpointOverrides( diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 7ae07155..64534294 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -85,6 +85,14 @@ export function oauthEndpointUrlError(value: string): string | undefined { if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return `"${trimmed}" is not an http(s) URL.`; } + // `new URL` accepts embedded credentials but Fetch rejects an HTTP(S) request + // URL that carries them, so without this the value would pass both the form + // and the runtime and then fail deep in the OAuth exchange — the failure mode + // this validation exists to prevent. (Sending credentials to an endpoint the + // flow discovered is not something to support quietly, either.) + if (parsed.username || parsed.password) { + return "URL must not contain a username or password."; + } return undefined; } @@ -232,18 +240,20 @@ export function withOAuthEndpointOverrides( const contentType = response.headers.get("content-type") ?? ""; if (!contentType.toLowerCase().includes("json")) return response; - const body = await response.text(); + // Read through a clone so every path that does NOT patch can return the + // caller's own `Response`, untouched. Rebuilding unconditionally would drop + // native properties a synthesized `Response` cannot carry (`url`, + // `redirected`, `type`) from responses this wrapper has no business + // altering — and would throw outright on a body-less 204/205, whose status + // Fetch forbids a body on. A clone also makes a non-JSON body a plain + // parse failure with nothing consumed. let parsed: unknown; try { - parsed = JSON.parse(body); + parsed = await response.clone().json(); } catch { - // Not JSON despite the header. The body is already consumed, so hand back - // an equivalent response rather than the drained original. - return responseWithBody(response, body); - } - if (!isAuthorizationServerMetadata(parsed)) { - return responseWithBody(response, body); + return response; } + if (!isAuthorizationServerMetadata(parsed)) return response; return responseWithBody( response, JSON.stringify(applyOAuthEndpointOverrides(parsed, overrides)), From ebc18e1d56eef13098deb2b8bbc757521eb3f330 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:38:52 -0400 Subject: [PATCH 188/941] fix: expand each expression independently, and reject invalid varspecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-5 review on #2035 — eight suppressed comments, all of them real. The consequential one overturns behavior I copied from the SDK and defended in two earlier rounds. RFC 6570 expands each expression independently; it carries no cross-expression state. The SDK tracks whether a query expression has already emitted and rewrites a later `{?two}`'s `?` to `&`. That looks friendlier and is wrong, and its own matcher says so: for `x{?one}{?two}` it expands to `x?one=1&two=2`, and `match("x?one=1&two=2")` on that same template returns **null**, while `match("x?one=1?two=2")` returns both variables. So the rewrite emitted a URI the advertised template cannot match — #1919 one level up. A server that wants a continuation advertises `{?one}{&two}`, which expands to `&` and matches; producing that shape is the server's choice to declare, not ours to infer. Also: - `{id:abc}` was silently treated as `{id}`, and a test codified it. RFC 6570's max-length is `%x31-39 0*3DIGIT`, so `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are invalid templates rather than templates with an ignorable modifier — and the SDK's constructor accepts them all, so nothing else rejects them. Strict expansion now throws; lenient returns the raw template. Discovery stays lenient so the panel still renders rather than going blank. - The TUI could submit `{a,b}` completely blank while the web panel blocked the same request: ink-form has no way to express "any one of these", so its members are left optional and the modal needs its own group check. Added, with tests — it was also below the coverage gate without them (functions 76.92%). - Added the integration test the config never had: it resolves the checked-in config and drives all four reads over a real transport, so a misspelt preset fails there rather than only when someone runs the repro by hand. - "All three clients" was false. The CLI has no template form and its `resources/read` passes the already-expanded `--uri` straight to `readResource`, so nothing here runs for it. Corrected in the README, AGENTS.md, and the module comment, and dropped a docblock still describing the SDK-delegation path removed last round. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 10 +- README.md | 2 +- .../tui/__tests__/ResourceTestModal.test.tsx | 47 ++++++ .../tui/src/components/ResourceTestModal.tsx | 27 ++++ .../web/src/test/core/mcp/uriTemplate.test.ts | 95 ++++++------ .../integration/mcp/rfc6570-templates.test.ts | 139 ++++++++++++++++++ clients/web/src/utils/uriTemplate.test.ts | 6 +- core/mcp/uriTemplate.ts | 133 ++++++++++------- 8 files changed, 353 insertions(+), 106 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/rfc6570-templates.test.ts diff --git a/AGENTS.md b/AGENTS.md index ccb2935c..4636e17e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,10 +74,12 @@ v2/main/ │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (uriTemplate.ts: RFC 6570 parse/classify/expand. -│ │ # The ONE expander for every client — the web -│ │ # Resources form and readResourceFromTemplate -│ │ # (TUI + CLI) — and every client derives its FORM -│ │ # FIELDS from it too (clients/tui uriTemplateToForm), +│ │ # The ONE expander for the web Resources form and +│ │ # readResourceFromTemplate (TUI); the CLI is NOT a +│ │ # consumer — it has no template form and passes an +│ │ # already-expanded --uri to readResource. Both +│ │ # consumers derive their FORM FIELDS from it too +│ │ # (clients/tui uriTemplateToForm), │ │ # which is what makes the sharing real: a form │ │ # submits under the names it rendered, so a mangled │ │ # name silently drops the value at expansion. The diff --git a/README.md b/README.md index d4b0e61e..c9f662e5 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r > The plain `foobar://events` resource is registered deliberately, not as filler. The SDK's `UriTemplate.match()` compiles `{?topic}` to a **required** `\?topic=([^&]+)`, so a template alone cannot serve the blank read — `match("foobar://events")` returns `null`. A real server exposes the unfiltered collection as its own resource; the showcase does the same so that step actually resolves. -All three clients expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI and CLI via `InspectorClient.readResourceFromTemplate` — and all three derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. +The web client and the TUI expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI via `InspectorClient.readResourceFromTemplate` — and both derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. (The CLI is not a consumer: it has no template form, and its `resources/read` passes the already-expanded `--uri` straight through.) The SDK's `UriTemplate` is still used, but only to *validate* a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx index d9de004d..0067d211 100644 --- a/clients/tui/__tests__/ResourceTestModal.test.tsx +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -77,6 +77,53 @@ const renderAndSubmit = async ( }; describe("ResourceTestModal", () => { + // RFC 6570 keeps a required expression satisfied by any ONE of its names, so + // `{a,b}` cannot be expressed with ink-form's per-field `required` flag and + // its members are left optional there. Without the modal's own group check + // the TUI would submit blank -- dropping the expression and reading a + // different resource -- while the web panel blocks the same request (#1919). + describe("required-group validation", () => { + it("refuses to submit when no name in a required group has a value", async () => { + const read = vi.fn(); + const { onClose, unmount } = await renderAndSubmit( + fakeClient(read), + makeTemplate({ uriTemplate: "x://{a,b}" }), + { a: "", b: "" }, + ); + expect(read).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + unmount(); + }); + + it("submits once any one name in the group has a value", async () => { + const read = vi.fn().mockResolvedValue({ + result: { contents: [] }, + expandedUri: "x://only-a", + }); + const { unmount } = await renderAndSubmit( + fakeClient(read), + makeTemplate({ uriTemplate: "x://{a,b}" }), + { a: "only-a", b: "" }, + ); + expect(read).toHaveBeenCalledWith("x://{a,b}", { a: "only-a", b: "" }); + unmount(); + }); + + it("does not block a template whose only expression is omittable", async () => { + const read = vi.fn().mockResolvedValue({ + result: { contents: [] }, + expandedUri: "x://events", + }); + const { unmount } = await renderAndSubmit( + fakeClient(read), + makeTemplate({ uriTemplate: "x://events{?topic}" }), + { topic: "" }, + ); + expect(read).toHaveBeenCalled(); + unmount(); + }); + }); + it("renders the form initially without invoking the client", async () => { const read = vi.fn(); const api = render( diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index 914b4bca..37d662a2 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -5,6 +5,10 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/client"; import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; +import { + hasRequiredValues, + requiredGroups, +} from "@inspector/core/mcp/uriTemplate.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; // Helper to extract error message from various error types @@ -133,6 +137,29 @@ export function ResourceTestModal({ const handleFormSubmit = async (values: Record<string, string>) => { if (!inspectorClient || !template) return; + // RFC 6570 keeps a required expression satisfied by any ONE of its names, + // so `{a,b}` cannot be expressed with ink-form's per-field `required` flag + // and its members are left optional there. Without this check the TUI would + // submit `{a,b}` completely blank -- dropping the expression and reading a + // different resource -- while the web panel blocks the same request (#1919). + const groups = requiredGroups(template.uriTemplate); + if (!hasRequiredValues(groups, values)) { + const unmet = groups + .filter( + (names) => !names.some((name) => (values[name] ?? "").length > 0), + ) + .map((names) => names.join(" or ")); + setResult({ + input: values, + output: null, + error: `Missing required template variable(s): ${unmet.join(", ")}`, + duration: 0, + uri: template.uriTemplate, + }); + setState("results"); + return; + } + setState("loading"); const startTime = Date.now(); diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index f0384f90..4b847131 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -18,6 +18,7 @@ describe("parseUriTemplate", () => { operator: "", varspecs: [{ name: "topic" }], names: ["topic"], + invalid: false, }, ]); }); @@ -31,6 +32,7 @@ describe("parseUriTemplate", () => { operator: "?", varspecs: [{ name: "a" }, { name: "b" }], names: ["a", "b"], + invalid: false, }, ]); }); @@ -137,8 +139,19 @@ describe("expandUriTemplate", () => { ); }); - it("joins two query expressions with & rather than a second ?", () => { + it("expands each query expression independently, per RFC 6570", () => { + // NOT `?one=1&two=2`. The SDK rewrites the second `?` to `&`, but measured + // against the pinned SDK its own matcher then rejects the result: + // match("x?one=1&two=2") on `x{?one}{?two}` is null, while + // match("x?one=1?two=2") returns both variables. A server wanting a + // continuation advertises `{?one}{&two}` -- see the test below. expect(expandUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" })).toBe( + "x://a?one=1?two=2", + ); + }); + + it("emits & only where the template asks for the continuation operator", () => { + expect(expandUriTemplate("x://a{?one}{&two}", { one: "1", two: "2" })).toBe( "x://a?one=1&two=2", ); }); @@ -229,11 +242,6 @@ describe("varspec modifiers", () => { ); }); - it("ignores a malformed modifier rather than inventing a truncation", () => { - expect(templateVariables("x://{id:}")[0].name).toBe("id"); - expect(expandUriTemplate("x://{id:}", { id: "abcdef" })).toBe("x://abcdef"); - }); - it("strips the explode modifier from the name", () => { expect(templateVariables("x://{id*}")[0].name).toBe("id"); }); @@ -330,60 +338,24 @@ describe("requiredGroups / hasRequiredValues", () => { }); }); -describe("cross-expression query joining", () => { - it("rewrites a second ? to & on the own-expansion path too", () => { - // Forced onto the own-expansion path by the `;` expression; the `?`-to-`&` - // rewrite must still apply, exactly as the SDK does it. +describe("expression independence", () => { + it("does not rewrite a later query expression on the own-expansion path", () => { expect( expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", one: "1", two: "2", }), - ).toBe("x://a;k=v?one=1&two=2"); + ).toBe("x://a;k=v?one=1?two=2"); }); - it("uses ? for the first query expression that actually emits", () => { + it("omits an expression with no value without affecting its neighbours", () => { expect( expandUriTemplate("x://a{;k}{?one}{?two}", { k: "v", two: "2" }), ).toBe("x://a;k=v?two=2"); }); }); -describe("strict vs lenient expansion", () => { - // `readResourceFromTemplate` wraps the thrown error with the template name; - // the web panel instead needs the raw template back, because an invalid - // template comes from the server and throwing would take out the panel. - it.each(["file:///{unclosed", "{a,b,c"])( - "strict throws on the invalid template %s", - (template) => { - expect(() => expandUriTemplateStrict(template, { x: "1" })).toThrow(); - }, - ); - - it.each(["file:///{unclosed", "{a,b,c"])( - "lenient returns %s unchanged", - (template) => { - expect(expandUriTemplate(template, { x: "1" })).toBe(template); - }, - ); - - it("validates syntax even when taking the own-expansion path", () => { - // `{;a}` forces own-expansion, and this module's parser treats the - // unclosed tail as literal text -- so without the unconditional SDK - // construction nothing would reject this. - expect(() => expandUriTemplateStrict("x://{;a}{b,c", { a: "1" })).toThrow(); - }); - - it("agrees with the lenient variant on a valid template", () => { - const template = "foobar://events{?topic}"; - const values = { topic: "foo/bar" }; - expect(expandUriTemplateStrict(template, values)).toBe( - expandUriTemplate(template, values), - ); - }); -}); - describe("allow-reserved encoding under + and #", () => { // The SDK uses `encodeURI` for these operators, which corrupts two classes // of value rather than merely over-escaping: measured, encodeURI("[::1]") @@ -465,3 +437,34 @@ describe("unreserved encoding under the non-reserved operators", () => { expect(expandUriTemplate("x://{v}", { v: "aZ0-._~" })).toBe("x://aZ0-._~"); }); }); + +describe("prefix-modifier grammar", () => { + // RFC 6570: max-length = %x31-39 0*3DIGIT -- 1..9999, no leading zero. + // The SDK's constructor accepts these shapes, so nothing else rejects them; + // treating `{id:abc}` as a plain `{id}` would send a URI the server never + // advertised, with nothing to alert anyone. + it.each(["x://{id:}", "x://{id:0}", "x://{id:abc}", "x://{id:10000}"])( + "strict rejects the invalid template %s", + (template) => { + expect(() => expandUriTemplateStrict(template, { id: "abcdef" })).toThrow( + /Invalid RFC 6570 varspec/, + ); + }, + ); + + it.each(["x://{id:}", "x://{id:abc}"])( + "lenient returns %s unchanged rather than guessing", + (template) => { + expect(expandUriTemplate(template, { id: "abcdef" })).toBe(template); + }, + ); + + it.each([ + ["x://{id:1}", "a"], + ["x://{id:9999}", "abcdef"], + ])("accepts the in-range modifier %s", (template, expected) => { + expect(expandUriTemplate(template, { id: "abcdef" })).toBe( + `x://${expected}`, + ); + }); +}); diff --git a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts new file mode 100644 index 00000000..5765c369 --- /dev/null +++ b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, afterEach } from "vitest"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { expandUriTemplate } from "@inspector/core/mcp/uriTemplate.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + loadConfig, + resolveConfig, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of `test-servers/configs/rfc6570-templates-http.json` — the + * documented manual reproduction for #1919. + * + * The unit tests assert what `expandUriTemplate` *produces*. They cannot assert + * that what it produces is what a spec-compliant server *accepts*, and that + * second half is the entire bug: the old string substitution emitted a URI the + * Inspector was perfectly happy with and the server rejected. So this drives + * both directions over a real transport — the encoded URI must resolve, and the + * unencoded one the old code produced must still be refused, so a regression + * cannot pass by quietly loosening the server. + * + * The server is built by **resolving the checked-in config** rather than by + * calling the fixture factories, which is the difference between covering the + * wiring and merely asserting the factory: a misspelt preset name in + * `preset-registry.ts`, or a config naming a preset that no longer exists, + * fails here instead of only when someone runs the repro by hand. + */ +describe("RFC 6570 resource templates over the wire (#1919)", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + const configPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../../../../test-servers/configs/rfc6570-templates-http.json", + ); + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // ignore + } + server = null; + } + }); + + /** + * Boot the showcase config. The harness picks the port rather than using the + * config's fixed one, so this cannot collide with a showcase server someone + * is running by hand. + */ + async function connectToShowcase(): Promise<InspectorClient> { + const resolved = resolveConfig(loadConfig(configPath)); + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("rfc6570-templates-test", "1.0.0"), + tools: resolved.tools, + resources: resolved.resources, + resourceTemplates: resolved.resourceTemplates, + }); + await started.start(); + server = started; + + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { environment: { transport: createTransportNode } }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("advertises both templates from the checked-in config", async () => { + const connected = await connectToShowcase(); + const { resourceTemplates } = await connected.listAllResourceTemplates(); + expect(resourceTemplates.map((entry) => entry.uriTemplate).sort()).toEqual([ + "foobar://events/{topic}", + "foobar://events{?topic}", + ]); + }); + + it("resolves the encoded URI the simple expression expands to", async () => { + const connected = await connectToShowcase(); + const uri = expandUriTemplate("foobar://events/{topic}", { + topic: "foo/bar", + }); + expect(uri).toBe("foobar://events/foo%2Fbar"); + + const { result } = await connected.readResource(uri); + expect(result.contents[0]?.uri).toBe(uri); + }); + + it("rejects the unencoded URI the old string substitution produced", async () => { + const connected = await connectToShowcase(); + // This is what the pre-fix client sent, and why #1919 was filed: the raw + // `/` creates a second path segment that the template cannot match. + await expect( + connected.readResource("foobar://events/foo/bar"), + ).rejects.toThrow(/not found/i); + }); + + it("resolves the encoded URI the query expression expands to", async () => { + const connected = await connectToShowcase(); + const uri = expandUriTemplate("foobar://events{?topic}", { + topic: "foo/bar", + }); + expect(uri).toBe("foobar://events?topic=foo%2Fbar"); + + const { result } = await connected.readResource(uri); + expect(result.contents[0]?.uri).toBe(uri); + }); + + it("resolves the base URI a blank query expression expands to", async () => { + const connected = await connectToShowcase(); + // RFC 6570 drops the whole expression when the variable is undefined, so + // an unfilled `topic` legitimately requests the unfiltered collection. The + // SDK's matcher compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so a + // template alone cannot serve this — the config registers the plain + // resource, which is what a real server would do. + const uri = expandUriTemplate("foobar://events{?topic}", { topic: "" }); + expect(uri).toBe("foobar://events"); + + const { result } = await connected.readResource(uri); + expect(result.contents[0]?.uri).toBe(uri); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index 8a50ff7e..feba12fe 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -35,10 +35,12 @@ describe("previewUriTemplate", () => { expect(previewUriTemplate("x://a{?one,two}", {})).toBe("x://a{?one,two}"); }); - it("still rewrites the second ? to & when both query expressions resolve", () => { + it("expands each query expression independently, matching the submit", () => { + // Not `?one=1&two=2` -- see the expander's tests: the SDK's own matcher + // rejects that for this template. The preview must show what will be sent. expect( previewUriTemplate("x://a{?one}{?two}", { one: "1", two: "2" }), - ).toBe("x://a?one=1&two=2"); + ).toBe("x://a?one=1?two=2"); }); it("restores a deferred expression that follows a resolved query expression", () => { diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 248abd92..b68a5c42 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -1,11 +1,16 @@ /** * RFC 6570 URI Template parsing and expansion, shared by every client (#1919). * - * This lives in `core/` rather than in a client so the web Resources form, the - * TUI, and the CLI cannot disagree about what a template means. Every one of - * them derives its form fields from {@link templateVariables} and expands - * through {@link expandUriTemplate} — the web panel directly, the TUI and CLI - * via `InspectorClient.readResourceFromTemplate`. + * This lives in `core/` rather than in a client so the web Resources form and + * the TUI cannot disagree about what a template means. Both derive their form + * fields from {@link templateVariables} and expand through + * {@link expandUriTemplate} — the web panel directly, the TUI via + * `InspectorClient.readResourceFromTemplate`. + * + * The **CLI is deliberately not a consumer**: it has no template form, and its + * `resources/read` passes the already-expanded `--uri` straight to + * `readResource` (see `clients/cli/src/handlers/run-method.ts`). Nothing here + * runs for it. * * ## Why this is not simply `new UriTemplate(t).expand(v)` * @@ -86,6 +91,12 @@ interface TemplateExpression { varspecs: VarSpec[]; /** Bare variable names, `*` and any `:length` modifier stripped. */ names: string[]; + /** + * True when a varspec carried a modifier that is not valid RFC 6570. Strict + * expansion rejects the template; discovery stays lenient so the panel can + * still render something rather than going blank. + */ + invalid: boolean; } export type TemplatePart = TemplateLiteral | TemplateExpression; @@ -108,8 +119,23 @@ export interface TemplateVariable { required: boolean; } -/** Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. */ -function parseVarSpec(raw: string): VarSpec | null { +/** + * RFC 6570's `max-length` production: `%x31-39 0*3DIGIT` — 1 to 9999, no + * leading zero. `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are all invalid + * *templates*, not templates with an ignorable modifier. + */ +const MAX_LENGTH_GRAMMAR = /^[1-9][0-9]{0,3}$/; + +/** + * Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. + * + * Returns `null` for an empty varspec (a stray comma) and `"invalid"` for one + * whose modifier does not match the grammar. The two are distinguished because + * the first is ignorable and the second must fail the template: silently + * treating `{id:abc}` as `{id}` would send a URI that does not match what the + * server advertised, with nothing to alert anyone. + */ +function parseVarSpec(raw: string): VarSpec | null | "invalid" { // The explode modifier is stripped rather than honored: it only changes how // a list or map value is joined, and every value reaching this module is a // single string. @@ -120,13 +146,9 @@ function parseVarSpec(raw: string): VarSpec | null { if (colon === -1) return { name: spec }; const name = spec.slice(0, colon); - const length = Number(spec.slice(colon + 1)); - // A malformed modifier (`{id:}`, `{id:abc}`) is not a valid varspec; keep the - // name and ignore the modifier rather than inventing a truncation. - if (name.length === 0) return null; - return Number.isInteger(length) && length > 0 - ? { name, maxLength: length } - : { name }; + const modifier = spec.slice(colon + 1); + if (name.length === 0 || !MAX_LENGTH_GRAMMAR.test(modifier)) return "invalid"; + return { name, maxLength: Number(modifier) }; } /** @@ -159,17 +181,17 @@ export function parseUriTemplate(uriTemplate: string): TemplatePart[] { } const body = uriTemplate.slice(i + 1, end); const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; - const varspecs = body - .slice(operator.length) - .split(",") - .map(parseVarSpec) - .filter((spec): spec is VarSpec => spec !== null); + const parsed = body.slice(operator.length).split(",").map(parseVarSpec); + const varspecs = parsed.filter( + (spec): spec is VarSpec => spec !== null && spec !== "invalid", + ); parts.push({ kind: "expression", source: uriTemplate.slice(i, end + 1), operator, varspecs, names: varspecs.map((spec) => spec.name), + invalid: parsed.includes("invalid"), }); i = end + 1; } @@ -375,30 +397,31 @@ function expandExpression( } /** - * Expands a whole parsed template, mirroring the SDK's `expand` — including its - * rule that a second query expression switches its leading `?` to `&`. + * Expands a whole parsed template. + * + * Each expression is expanded **independently**, which is what RFC 6570 + * specifies — expansion carries no cross-expression state. The SDK instead + * tracks whether a query expression has already emitted and rewrites a later + * `{?two}`'s leading `?` to `&`. That looks friendlier and is wrong: measured + * against the pinned SDK, `x{?one}{?two}` expands to `x?one=1&two=2`, and + * `UriTemplate.match` on that same template *rejects* it — `match("x?one=1&two=2")` + * is `null`, while `match("x?one=1?two=2")` returns both variables. So the + * rewrite emits a URI the advertised template cannot match, which is precisely + * the failure #1919 is about, one level up. + * + * A server that wants a continuation advertises it: `{?one}{&two}` expands to + * `x?one=1&two=2` and matches. Producing that shape is the server's choice to + * declare, not ours to infer. */ export function expandParts( parts: TemplatePart[], values: Record<string, string>, ): string { - let result = ""; - let hasQueryParam = false; - - for (const part of parts) { - if (part.kind === "literal") { - result += part.text; - continue; - } - const expanded = expandExpression(part, values); - if (!expanded) continue; - - const isQuery = part.operator === "?" || part.operator === "&"; - result += isQuery && hasQueryParam ? `&${expanded.slice(1)}` : expanded; - if (isQuery) hasQueryParam = true; - } - - return result; + return parts + .map((part) => + part.kind === "literal" ? part.text : expandExpression(part, values), + ) + .join(""); } /** @@ -406,29 +429,33 @@ export function expandParts( * each value according to its operator, and omitting expressions whose * variables were left blank. **Throws** on a template that is not valid. * - * Templates the SDK handles correctly still go through the SDK, so the two - * cannot drift on the ordinary path; only a template containing at least one - * expression from the table above is expanded here instead. That is a - * whole-template switch rather than a per-expression splice so the `?`-to-`&` - * rewrite always sees every expression that actually produced output. + * Every expression is expanded by {@link expandParts}; the SDK's `UriTemplate` + * is constructed only because that is what rejects an unclosed expression, and + * callers such as `readResourceFromTemplate` wrap the error it throws. Its own + * `expand` is deliberately unused — see the module comment for the five shapes + * it gets wrong. * - * The SDK template is constructed *before* choosing a path, and unconditionally: - * that construction is what validates the syntax and throws `Unclosed template - * expression`, and callers such as `readResourceFromTemplate` wrap that error. - * Skipping it on the own-expansion path would silently accept a template like - * `{;a}{b,c` — this module's parser treats the unclosed tail as literal text, - * so nothing else would object. + * Its constructor is not a complete validator either: it accepts `{id:abc}`, + * whose modifier is not RFC 6570's `max-length` production. Treating that as a + * plain `{id}` would send a URI the server never advertised, so the owned + * parser's verdict is checked too. */ export function expandUriTemplateStrict( uriTemplate: string, values: Record<string, string>, ): string { const defined = definedValues(values); - // Constructed purely to validate: this is what throws `Unclosed template - // expression`, and callers such as `readResourceFromTemplate` wrap that - // error. Its `expand` is deliberately not used — see the module comment. new UriTemplate(uriTemplate); - return expandParts(parseUriTemplate(uriTemplate), defined); + const parts = parseUriTemplate(uriTemplate); + const bad = parts.find((part) => part.kind === "expression" && part.invalid); + if (bad) { + throw new Error( + `Invalid RFC 6570 varspec in "${uriTemplate}": ${ + bad.kind === "expression" ? bad.source : "" + } — a prefix modifier must be 1-9999 with no leading zero.`, + ); + } + return expandParts(parts, defined); } /** From ed99617c53619a8d62e2b0b506b826bf1feaac21 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:40:07 -0400 Subject: [PATCH 189/941] fix(web): lock header rows while submitting, name their clear buttons (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review follow-ups on #2038, all three from the suppressed set. The header rows stayed editable while a save was in flight, unlike the rest of the modal — a slow request let a user edit a row whose value had already been captured, and the modal then closed having persisted the earlier one. `KeyValueRows` takes a `disabled` prop and the modal passes `submitting`. Each row's clear buttons were still named the bare "Clear", so a screen reader saw six indistinguishable buttons across three rows. They now name the field they empty ("Clear header name, Cookie"). The App-level headers-into-settings merge — the seam that distinguishes an edit from a clone, and where the credential copy lived — moves into `utils/serverSettingsPatch.ts` with its own tests. App.tsx is outside the coverage gate, so inline it could not be covered at all. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 29 ++---- .../KeyValueRows/KeyValueRows.test.tsx | 34 +++++-- .../elements/KeyValueRows/KeyValueRows.tsx | 21 ++++- .../ServerConfigModal/ServerConfigModal.tsx | 1 + .../ServerSettingsForm.test.tsx | 15 +-- .../web/src/utils/serverSettingsPatch.test.ts | 93 +++++++++++++++++++ clients/web/src/utils/serverSettingsPatch.ts | 42 +++++++++ 7 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 clients/web/src/utils/serverSettingsPatch.test.ts create mode 100644 clients/web/src/utils/serverSettingsPatch.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 4c6598bf..a32ecb90 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -139,6 +139,7 @@ import { type ServerConfigModalMode, } from "./components/groups/ServerConfigModal/ServerConfigModal"; import type { KeyValuePair } from "./components/elements/KeyValueRows/KeyValueRows"; +import { buildHeaderSettingsPatch } from "./utils/serverSettingsPatch"; import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; import { ClientSettingsModal } from "./components/groups/ClientSettingsModal/ClientSettingsModal"; import { @@ -3927,25 +3928,15 @@ function App() { const onConfigSubmit = useCallback( async (id: string, config: MCPServerConfig, headers: KeyValuePair[]) => { // Headers live on the entry's `settings`, not on the transport config, - // so they're folded back in here (#1915). - // - // Only an EDIT carries the target's other settings forward. `settings` - // replaces the whole node, so an edit must re-send the fields this modal - // doesn't expose (metadata, timeouts, OAuth credentials, roots) or they - // would be dropped. A CLONE must not: `configModalTarget` there is the - // *source* server, and spreading it would copy that server's OAuth - // client secret and every behavior flag onto a new entry the user only - // gave a URL and some headers. - const isEdit = configModal?.mode === "edit" && !!configModal.targetId; - const existing = isEdit ? configModalTarget?.settings : undefined; - // Send `settings` only when it would change something: with no headers - // on either side there is nothing to write, and omitting the key is what - // makes the backend preserve the node it already has. - const settingsChanged = - headers.length > 0 || (existing?.headers.length ?? 0) > 0; - const settings = settingsChanged - ? { ...(existing ?? EMPTY_SETTINGS), headers } - : undefined; + // so they're folded back in here (#1915). The edit-vs-clone rules live + // in the helper, which is unit-tested — this file is outside the + // coverage gate and that distinction is where credentials can leak. + const settings = buildHeaderSettingsPatch( + configModal?.mode ?? "add", + configModalTarget?.settings, + headers, + EMPTY_SETTINGS, + ); if (configModal?.mode === "edit" && configModal.targetId) { const originalId = configModal.targetId; await updateServer(originalId, id, config, settings); diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx index ef96bb8d..09fceca7 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx @@ -4,13 +4,14 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { KeyValueRows } from "./KeyValueRows"; describe("KeyValueRows", () => { - function setup(items: { key: string; value: string }[]) { + function setup(items: { key: string; value: string }[], disabled?: boolean) { const onChange = vi.fn(); const onRemove = vi.fn(); renderWithMantine( <KeyValueRows items={items} entityLabel="header" + disabled={disabled} onChange={onChange} onRemove={onRemove} />, @@ -57,21 +58,42 @@ describe("KeyValueRows", () => { expect(onChange).toHaveBeenLastCalledWith(0, "X", "12"); }); - it("clears a key or a value through its Clear button", async () => { + it("clears a key or a value through its own named Clear button", async () => { const user = userEvent.setup({ delay: null }); const { onChange } = setup([{ key: "X", value: "1" }]); - const clearButtons = screen.getAllByRole("button", { name: "Clear" }); - await user.click(clearButtons[0]!); + // A bare "Clear" repeated per field would be indistinguishable across + // rows, so each clear button names the field it empties. + await user.click( + screen.getByRole("button", { name: "Clear header name, X" }), + ); expect(onChange).toHaveBeenLastCalledWith(0, "", "1"); - await user.click(clearButtons[1]!); + await user.click( + screen.getByRole("button", { name: "Clear header value, X" }), + ); expect(onChange).toHaveBeenLastCalledWith(0, "X", ""); }); it("omits the Clear button for an empty key or value", () => { setup([{ key: "", value: "" }]); - expect(screen.queryAllByRole("button", { name: "Clear" })).toHaveLength(0); + expect(screen.queryAllByRole("button", { name: /^Clear/ })).toHaveLength(0); + }); + + it("locks every control when disabled", () => { + setup([{ key: "X", value: "1" }], true); + expect( + screen.getByRole("textbox", { name: "header name, X" }), + ).toBeDisabled(); + expect( + screen.getByRole("textbox", { name: "header value, X" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Clear header name, X" }), + ).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Remove header, X" }), + ).toBeDisabled(); }); it("removes the clicked row", async () => { diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx index c41bd9c3..cf03e8e1 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx @@ -13,9 +13,16 @@ export interface KeyValueRowsProps { * to build each control's `aria-label`: the section heading and the "Key" / * "Value" placeholders are not programmatically associated with the inputs, * so without it an assistive technology cannot tell one list's key box from - * another's, and the remove button announces only "X". + * another's, and the remove button announces only "X". The clear buttons are + * named the same way — a bare "Clear" repeated six times across three rows + * tells a screen-reader user nothing about which field it empties. */ entityLabel: string; + /** + * Lock every control in the list — used while a submit is in flight, so the + * rows can't drift out of sync with the payload the caller already captured. + */ + disabled?: boolean; onChange: (index: number, key: string, value: string) => void; onRemove: (index: number) => void; } @@ -42,6 +49,7 @@ const RemoveIcon = ActionIcon.withProps({ export function KeyValueRows({ items, entityLabel, + disabled, onChange, onRemove, }: KeyValueRowsProps) { @@ -55,12 +63,15 @@ export function KeyValueRows({ placeholder="Key" aria-label={`${entityLabel} name, ${rowLabel}`} value={item.key} + disabled={disabled} onChange={(e) => onChange(index, e.currentTarget.value, item.value) } rightSection={ item.key ? ( <ClearButton + aria-label={`Clear ${entityLabel} name, ${rowLabel}`} + disabled={disabled} onClick={() => onChange(index, "", item.value)} /> ) : null @@ -70,15 +81,21 @@ export function KeyValueRows({ placeholder="Value" aria-label={`${entityLabel} value, ${rowLabel}`} value={item.value} + disabled={disabled} onChange={(e) => onChange(index, item.key, e.currentTarget.value)} rightSection={ item.value ? ( - <ClearButton onClick={() => onChange(index, item.key, "")} /> + <ClearButton + aria-label={`Clear ${entityLabel} value, ${rowLabel}`} + disabled={disabled} + onClick={() => onChange(index, item.key, "")} + /> ) : null } /> <RemoveIcon aria-label={`Remove ${entityLabel}, ${rowLabel}`} + disabled={disabled} onClick={() => onRemove(index)} > X diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index 24089ff5..0361aa32 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -504,6 +504,7 @@ export function ServerConfigModal({ <KeyValueRows items={form.headers} entityLabel="header" + disabled={submitting} onChange={changeHeader} onRemove={removeHeader} /> diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index daf31021..8bc001c7 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -8,12 +8,15 @@ import { type ServerSettingsSection, } from "./ServerSettingsForm"; -/** Find the "Clear" button living in the rightSection of `input`'s field. */ +/** Find the clear button living in the rightSection of `input`'s field. The + * name is a prefix match because a KeyValueRows field names its clear button + * for the row it belongs to ("Clear header name, Cookie"), while a standalone + * field keeps the bare "Clear". */ function clearButtonFor(input: HTMLElement): HTMLElement { const root = input.closest('[class*="mantine-TextInput-root"]') ?? input.closest('[class*="Input-wrapper"]'); - return within(root as HTMLElement).getByRole("button", { name: "Clear" }); + return within(root as HTMLElement).getByRole("button", { name: /^Clear/ }); } const emptySettings: InspectorServerSettings = { @@ -1391,12 +1394,12 @@ describe("ServerSettingsForm", () => { expect( within( keyInput.closest('[class*="Input-wrapper"]') as HTMLElement, - ).queryByRole("button", { name: "Clear" }), + ).queryByRole("button", { name: /^Clear/ }), ).toBeNull(); expect( within( valueInput.closest('[class*="Input-wrapper"]') as HTMLElement, - ).queryByRole("button", { name: "Clear" }), + ).queryByRole("button", { name: /^Clear/ }), ).toBeNull(); }); @@ -1419,12 +1422,12 @@ describe("ServerSettingsForm", () => { expect( within( uriInput.closest('[class*="Input-wrapper"]') as HTMLElement, - ).queryByRole("button", { name: "Clear" }), + ).queryByRole("button", { name: /^Clear/ }), ).toBeNull(); expect( within( nameInput.closest('[class*="Input-wrapper"]') as HTMLElement, - ).queryByRole("button", { name: "Clear" }), + ).queryByRole("button", { name: /^Clear/ }), ).toBeNull(); // Typing into the URI threads the empty name through (`root.name ?? ""`). await user.type(uriInput, "f"); diff --git a/clients/web/src/utils/serverSettingsPatch.test.ts b/clients/web/src/utils/serverSettingsPatch.test.ts new file mode 100644 index 00000000..12d7c081 --- /dev/null +++ b/clients/web/src/utils/serverSettingsPatch.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import { buildHeaderSettingsPatch } from "./serverSettingsPatch"; + +const EMPTY: InspectorServerSettings = { + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + autoRefreshOnListChanged: false, + paginatedLists: false, + maxFetchRequests: 1000, + roots: [], +}; + +// A server carrying settings the modal never shows — the fields an edit must +// preserve and a clone must not copy. +const POPULATED: InspectorServerSettings = { + ...EMPTY, + headers: [{ key: "X-Old", value: "1" }], + metadata: [{ key: "trace", value: "abc" }], + connectionTimeout: 5000, + oauthClientId: "cid", + oauthClientSecret: "shhh", + roots: [{ uri: "file:///project", name: "Project" }], +}; + +const NEW_HEADERS = [{ key: "Cookie", value: "branch=x" }]; + +describe("buildHeaderSettingsPatch", () => { + it("sends nothing when there are no headers on either side", () => { + expect( + buildHeaderSettingsPatch("add", undefined, [], EMPTY), + ).toBeUndefined(); + expect(buildHeaderSettingsPatch("edit", EMPTY, [], EMPTY)).toBeUndefined(); + }); + + it("carries the target's other settings forward on an edit", () => { + const patch = buildHeaderSettingsPatch( + "edit", + POPULATED, + NEW_HEADERS, + EMPTY, + ); + expect(patch).toEqual({ ...POPULATED, headers: NEW_HEADERS }); + // The node is replaced wholesale, so the fields the modal doesn't show + // have to travel with it. + expect(patch?.metadata).toEqual([{ key: "trace", value: "abc" }]); + expect(patch?.oauthClientSecret).toBe("shhh"); + expect(patch?.connectionTimeout).toBe(5000); + }); + + it("still sends on an edit that clears the last header", () => { + expect(buildHeaderSettingsPatch("edit", POPULATED, [], EMPTY)).toEqual({ + ...POPULATED, + headers: [], + }); + }); + + it("does not copy the source server's settings on a clone", () => { + // The regression this guards: `configModalTarget` in clone mode is the + // SOURCE entry, so spreading it put that server's OAuth client secret on a + // brand-new one. + const patch = buildHeaderSettingsPatch( + "clone", + POPULATED, + NEW_HEADERS, + EMPTY, + ); + expect(patch).toEqual({ ...EMPTY, headers: NEW_HEADERS }); + expect(patch?.oauthClientSecret).toBeUndefined(); + expect(patch?.oauthClientId).toBeUndefined(); + expect(patch?.metadata).toEqual([]); + expect(patch?.roots).toEqual([]); + expect(patch?.connectionTimeout).toBe(0); + }); + + it("builds from the empty shape on an add", () => { + expect( + buildHeaderSettingsPatch("add", undefined, NEW_HEADERS, EMPTY), + ).toEqual({ ...EMPTY, headers: NEW_HEADERS }); + }); + + it("ignores a stale target on a clone with no headers", () => { + // A clone of a server that HAS headers, submitted with none: nothing about + // the new entry needs a settings node. + expect( + buildHeaderSettingsPatch("clone", POPULATED, [], EMPTY), + ).toBeUndefined(); + }); +}); diff --git a/clients/web/src/utils/serverSettingsPatch.ts b/clients/web/src/utils/serverSettingsPatch.ts new file mode 100644 index 00000000..b2b51a81 --- /dev/null +++ b/clients/web/src/utils/serverSettingsPatch.ts @@ -0,0 +1,42 @@ +import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; +import type { ServerConfigModalMode } from "../components/groups/ServerConfigModal/ServerConfigModal"; +import type { KeyValuePair } from "../components/elements/KeyValueRows/KeyValueRows"; + +/** + * Decide what `settings` node ServerConfigModal's submit should send for the + * custom headers it just collected (#1915). + * + * Two rules, both load-bearing: + * + * 1. **Only an edit carries the target's other settings forward.** The + * backend replaces the whole node when one is sent, so an edit must + * re-send the fields the modal doesn't expose (metadata, timeouts, OAuth + * credentials, roots) or they'd be dropped. An **add or clone must not**: + * the modal's target in clone mode is the *source* server, so spreading it + * would copy that server's OAuth client secret and behavior flags onto a + * new entry the user only gave a URL and some headers. + * 2. **`undefined` means "don't send the key at all."** Omitting `settings` + * is what makes the backend preserve the node it already has, so with no + * headers on either side there is nothing to write and nothing to clear. + * Clearing the last header still sends — `existing` had headers, so the + * node must be rewritten without them. + * + * Lives here rather than inline in App.tsx so this seam is unit-testable: + * App.tsx is outside the coverage gate, and the edit-vs-clone distinction is + * exactly where credentials leaked before. + * + * @param emptySettings the app's blank settings shape, used as the base for an + * add or clone. + */ +export function buildHeaderSettingsPatch( + mode: ServerConfigModalMode, + existingSettings: InspectorServerSettings | undefined, + headers: KeyValuePair[], + emptySettings: InspectorServerSettings, +): InspectorServerSettings | undefined { + const existing = mode === "edit" ? existingSettings : undefined; + const changesSomething = + headers.length > 0 || (existing?.headers.length ?? 0) > 0; + if (!changesSomething) return undefined; + return { ...(existing ?? emptySettings), headers }; +} From e5c20608bf0fb1d37a7bcc76c601ec6a294e325d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:52:09 -0400 Subject: [PATCH 190/941] fix(auth): never read a stream, and say what the issuer override does not do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the fetch wrapper: The content-type test was a substring match for "json", which also matches `application/x-ndjson` — how the streamable-HTTP transport can serve its long-lived server-push channel (`isLongLivedStreamResponse`). Awaiting `.json()` on a clone of an unbounded stream never resolves, so the MCP connection would hang for as long as an override was configured. It now matches the media type exactly (`application/json` or a `+json` suffix), with a regression test driven by a stream that never closes. The overrides also leave `issuer` as discovery returned it, so pointing one at an authorization server that advertises a different issuer is rejected on callback by the RFC 9207 / SEP-2352 mix-up defense. That check is deliberately not weakened; the limit is now stated on the module, in the field description, and in the configuration guide. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ServerSettingsForm/ServerSettingsForm.tsx | 2 +- .../test/core/auth/endpointOverrides.test.ts | 40 +++++++++++++++++++ core/auth/endpointOverrides.ts | 38 ++++++++++++++++-- docs/mcp-server-configuration.md | 2 + 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 7c80dbab..b2a786c6 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -875,7 +875,7 @@ export function ServerSettingsForm({ </Stack> <ClearableTextInput label="Authorization URL override" - description={`Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead.${endpointOverrideEmaNote}`} + description={`Leave blank to use the authorization_endpoint the authorization server's metadata advertises. Set it to point this server at a development or staging authorization server instead. Endpoints only — the discovered issuer is unchanged, so an authorization server advertising a different issuer is rejected on callback (RFC 9207).${endpointOverrideEmaNote}`} placeholder="https://staging.auth.example.com/authorize" value={settings.oauthAuthorizationUrl ?? ""} error={oauthEndpointUrlError( diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index 0d9d214e..f44a4afc 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -282,6 +282,46 @@ describe("withOAuthEndpointOverrides", () => { await expect(response.text()).resolves.toBe("not json at all"); }); + // `application/x-ndjson` satisfies a naive `includes("json")` test, and the + // streamable-HTTP transport serves its long-lived server-push channel that way + // (see `isLongLivedStreamResponse`). Awaiting `.json()` on a clone of an + // unbounded stream never resolves, so the MCP connection would hang for as + // long as an override was configured. Regression test for PR #2037 review. + it("does not read an unbounded NDJSON stream", async () => { + // A body that never closes: if the wrapper reads it, this test times out. + const stream = new ReadableStream<Uint8Array>({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0"}\n')); + // Deliberately never closed. + }, + }); + const original = new Response(stream, { + headers: { "content-type": "application/x-ndjson" }, + }); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); + await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe( + original, + ); + await original.body?.cancel(); + }); + + it("ignores content-type parameters when deciding a body is JSON", async () => { + const wrapped = withOAuthEndpointOverrides( + passThrough( + new Response(JSON.stringify(METADATA), { + headers: { "content-type": "application/json; charset=utf-8" }, + }), + ), + () => ({ tokenUrl: STAGING_TOKEN }), + ); + const response = await wrapped("https://as.example.com/x"); + await expect(response.json()).resolves.toMatchObject({ + token_endpoint: STAGING_TOKEN, + }); + }); + // Fetch forbids a body on 204/205, so rebuilding one would throw. A JSON // content-type on an empty response is unusual but legal, and it must not // take down an unrelated transport request. diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index 64534294..e0e01ae6 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -22,7 +22,7 @@ * sees both is the `fetchFn` the SDK uses for discovery, so the override is * applied to the metadata document in flight. * - * Three consequences worth knowing: + * Four consequences worth knowing: * * - **A metadata document is required.** When discovery returns nothing, the SDK * falls back to `/authorize` and `/token` on the authorization server's origin @@ -36,6 +36,17 @@ * suppresses the overrides when the server is enterprise-managed. This mirrors * `redirectToExternalAuthorization` skipping the custom authorization * parameters (#2018). + * - **They redirect endpoints, they do not re-point the issuer.** `issuer` is + * left exactly as discovery returned it, deliberately: it is the authorization + * server's *identity*, and it is what RFC 9207 / SEP-2352 check the callback's + * `iss` against to defend against an authorization-server mix-up. So these + * overrides fit alternate endpoints of the same logical issuer (a staging + * deployment fronting the same issuer, a local proxy). Aim one at an + * authorization server that advertises a *different* issuer and the callback + * is rejected as an issuer mismatch before the code is redeemed — which is the + * mix-up defense working. Suppressing that check to make the override "work" + * would trade a real security property for a debugging convenience, so the + * limit is documented rather than removed. * - **The Network tab shows the patched document.** The wrapper is applied to the * base fetch, inside the request tracker, so what the tab renders is the * metadata as the flow consumed it. That is the useful reading: it explains why @@ -185,6 +196,27 @@ export function applyOAuthEndpointOverrides( return patched; } +/** + * Whether a `content-type` names a **whole JSON document** — something that can + * be read to completion. + * + * This must be an exact media-type match, not a substring test for "json". The + * streamable-HTTP transport's long-lived server-push channel can be served as + * `application/x-ndjson` (see `isLongLivedStreamResponse` in + * `core/mcp/fetchTracking.ts`), which is an unbounded stream: awaiting `.json()` + * on a clone of it would never resolve, and since this wrapper awaits before + * returning, the MCP connection would hang for as long as an override is + * configured. Metadata documents are plain `application/json`, so the narrow + * test loses nothing. The `+json` structured suffix is accepted for the same + * reason it exists — it names a concrete document, not a stream. + */ +function isJsonDocumentResponse(contentType: string | null): boolean { + if (!contentType) return false; + // Strip parameters (`; charset=utf-8`) and compare the media type itself. + const mediaType = contentType.split(";")[0].trim().toLowerCase(); + return mediaType === "application/json" || mediaType.endsWith("+json"); +} + /** Rebuild a response around a replacement body, preserving status/headers. */ function responseWithBody(response: Response, body: string): Response { const headers = new Headers(response.headers); @@ -237,8 +269,8 @@ export function withOAuthEndpointOverrides( const overrides = resolveOverrides(); if (!overrides) return response; if (!response.ok) return response; - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("json")) return response; + if (!isJsonDocumentResponse(response.headers.get("content-type"))) + return response; // Read through a clone so every path that does NOT patch can return the // caller's own `Response`, untouched. Rebuilding unconditionally would drop diff --git a/docs/mcp-server-configuration.md b/docs/mcp-server-configuration.md index c317adfb..ac105f3a 100644 --- a/docs/mcp-server-configuration.md +++ b/docs/mcp-server-configuration.md @@ -181,6 +181,8 @@ These have no analog in the broader `mcp.json` ecosystem. Each is **omitted on w `oauth.authorizationUrl` and `oauth.tokenUrl` override the `authorization_endpoint` and `token_endpoint` that authorization-server metadata discovery resolved. The Inspector deliberately has no such fields by default — it resolves both from the AS's metadata document, exactly as a real MCP host does — but a server under development often advertises its *production* authorization server while you want to hit staging. Set either (they are independent) to an absolute `http(s)` URL and it replaces what the metadata returned, for both the authorization request and the token request; leave blank to use discovery. A malformed value is flagged inline in the form and dropped with a warning at connect time rather than failing the connection. Edit them in Server Settings → Authorization ("Authorization URL override" / "Token URL override"). +They redirect the **endpoints**, not the authorization server's identity: `issuer` is left exactly as discovery returned it, because that is what RFC 9207 / SEP-2352 validate the callback's `iss` against to defend against an authorization-server mix-up. So they fit alternate endpoints of the *same* logical issuer — a staging deployment fronting the same issuer, a local proxy. Point one at an authorization server advertising a **different** issuer and the callback is rejected as an issuer mismatch before the code is redeemed; that is the mix-up defense working, and it is not disabled to make the override succeed. + They are **not applied under enterprise-managed authorization** (`oauth.enterpriseManaged`), for the same reason `oauth.authorizationParams` isn't: that flow authorizes against the enterprise IdP — a different authorization server, whose OIDC discovery would otherwise be rewritten too, pointing the IdP login (or the IdP code exchange) at the resource server's authorization server. > Because the override is applied to the *discovered metadata document*, a server whose authorization server publishes no metadata at all is unaffected — there the SDK falls back to `/authorize` and `/token` on the AS origin, and there is nothing to override. From d830213796e579a5764924cbabd20ab9853959e3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:52:43 -0400 Subject: [PATCH 191/941] =?UTF-8?q?fix:=20address=20round-2=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20disconnect,=20lint-project=20gap,=20coverag?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 2 (PR #2039) surfaced no new visible comments but six suppressed ones. Four were actionable. `clients/tui/src/App.tsx` — a stale-completion race in the new banner: start disconnecting server A, switch to B, then let A's disconnect reject, and A's failure appears under B. Rapid same-server retries had the same shape, and the stale attempt is the one most likely to reject. Claim a monotonic attempt token before awaiting and publish only if it is still current and the server has not changed; `handleConnect` bumps the same counter so a connect retires an in-flight disconnect. The rejection is still owned when superseded — just not published. The regression test drives a deferred rejection across a server switch, and fails without the guard. `tsconfig.lint.json` — the root type-aware block matches `test-servers/src/**/*.tsx` but the parser project omitted that extension, so such a file would have failed with "not found in any of the provided project(s)" instead of being linted. Added, and AGENTS.md now states the invariant: the include must stay a superset of the lint globs. `clients/web/server/{server,vite-hono-plugin}.ts` — the `open()` rejection branch was untested, and duplicated. Both were fixed by removing the duplication: `openBrowser` now lives beside `printServerBanner` in `web-server-config.ts`, with one implementation to test. Two copies of a one-line `.catch` is two chances for one to drift back to a bare `open(url)`. The dev plugin is not booted in tests — it returns early under VITEST by design — but it calls the same tested helper. `core/mcp/remote/node/server.ts` — `closeAbortedStream`'s rejecting-close path, the entire reason the helper exists, had no test. Exported it (as this file already does for `requestIdForSendWait` / `mcpParamHeadersOnly`) and covered both legs, asserting no unhandledRejection fires. Also corrects AGENTS.md, which said each client lists "both" tsconfig projects — true for cli/tui/launcher, but web lists four leaf projects. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- clients/tui/__tests__/App.test.tsx | 25 +++ clients/tui/src/App.tsx | 23 ++- clients/web/server/server.ts | 12 +- clients/web/server/vite-hono-plugin.ts | 11 +- clients/web/server/web-server-config.ts | 19 +++ .../mcp/remote/server-extra-coverage.test.ts | 34 ++++ .../server/server-auto-open.test.ts | 149 ++++++++++++++++++ core/mcp/remote/node/server.ts | 2 +- tsconfig.lint.json | 1 + 10 files changed, 259 insertions(+), 19 deletions(-) create mode 100644 clients/web/src/test/integration/server/server-auto-open.test.ts diff --git a/AGENTS.md b/AGENTS.md index 93d0eaac..db73995c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -723,7 +723,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **The class it catches is invisible at review time.** A floated call reads like an awaited one minus four characters, and the unhandled rejection it produces surfaces somewhere else entirely — a different test, a different file, a stack pointing at SDK internals. Two un-held `client.callTool(...)` promises made `npm run ci` unpassable in #1947: `disconnect()` rejected them with `Connection closed`, the unhandled rejection failed the whole vitest run, and the chain aborted at `coverage`, silently skipping `verify:build-gate`, `smoke`, and Storybook. Attributing it took a full investigation; the fix was two lines. - **Prefer holding and settling the promise.** `void` is an escape hatch, not a fix — it is visible at review time (strictly better than nothing) but still discards the rejection. Reach for it only when the callee already owns its failures (it ends in a `catch` that surfaces the message) or the caller genuinely cannot await — a synchronous `useEffect` body, an Ink `useInput` key handler, a Hono `stream.onAbort` listener. Say **which** of those it is in a one-line comment; an unexplained `void` is a review finding. Where the callee does *not* own its failures, give it a `catch` rather than voiding the call (see `handleDisconnect` in `clients/tui/src/App.tsx`), or terminate with `.catch(…)` at the call site (see `open(url)` in `clients/web/server/{server,vite-hono-plugin}.ts`). - **In Storybook play functions, `expect(...)` from `storybook/test` returns a promise.** Storybook instruments it so the interactions panel can trace each assertion, so every `expect` in a play function is awaited — as is any shared helper that wraps one (`src/test/scrollAreaStoryAssertions.ts` is `async` for this reason). - - **The rule is type-aware, so each scope's ESLint config carries a parser project.** The four clients point at their own tsconfig projects (both of them, since `src` and `__tests__`/`server` live in different ones); the root config points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type *checking* for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **When adding a first-party TS location to the root lint scope, add it to `tsconfig.lint.json`'s `include` too**, or the parser fails it outright with "was not found in any of the provided project(s)". Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately. + - **The rule is type-aware, so each scope's ESLint config carries a parser project.** Each client's config must name **every leaf project covering its lint surface**, since the parser needs a program that literally *contains* the file — for cli, tui, and launcher that is the two they already typecheck (`tsconfig.json` + `tsconfig.test.json`, `src` in the first and `__tests__` only in the second), and for **web it is four** (`tsconfig.app.json`, `tsconfig.node.json`, `tsconfig.storybook.json`, `tsconfig.test.json`) — web's `tsconfig.json` is a solution file with `files: []`, so naming it alone would contain nothing. Adding a leaf project to a client means adding it here too. The root config instead points at **`tsconfig.lint.json`**, which exists solely to give the parser a program covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts` — none of which is rooted in a tsconfig of its own. That file emits nothing and gates nothing: type *checking* for those sources stays where it was (`core/` through `clients/web`'s `tsc -b`, `test-servers/src` through `clients/cli`'s test project). It sets `moduleResolution: bundler` deliberately — `core/` uses extensionless relative imports, which NodeNext fails to resolve, degrading every import to `any` so the rule silently stops seeing promises at all. **Its `include` must stay a superset of the root config's type-aware `files` globs** — a file the lint block matches but the project omits fails outright with "was not found in any of the provided project(s)" rather than being checked, so widening one means widening the other (that is why the `include` carries `test-servers/src/**/*.tsx`, which nothing has produced yet). Note also that a tsconfig `include` does **not** expand braces — `core/**/*.{ts,tsx}` matches nothing; list the extensions separately. - Type-aware linting costs real time: `clients/web`'s `eslint .` went from ~8s to ~19s, and `lint` runs inside `validate`, the fast inner-loop check. That is the price of the guarantee; if it needs reducing later, narrowing the projects each scope loads is the lever, not dropping the rule. ## Web source layout: `src/lib` vs `src/utils` diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 8a5124f5..4f7a25c8 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -697,6 +697,31 @@ describe("App (foundation)", () => { expect(r.lastFrame() ?? "").not.toContain("Disconnect failed"); }); + it("drops a disconnect rejection that lands after the user switched servers", async () => { + // The stale attempt is the one that usually rejects, so without an + // attempt token server alpha's failure would surface in beta's header. + h.ctrl.status = "connected"; + let rejectDisconnect: (err: Error) => void = () => {}; + h.disconnect.mockImplementationOnce( + () => + new Promise<void>((_resolve, reject) => { + rejectDisconnect = reject; + }), + ); + const r = await mount(stdioServer()); + r.stdin.write("d"); // alpha's disconnect starts, and hangs + await tick(); + await press(r, [DOWN]); // switch to beta + await expectFrame(r, "beta"); + rejectDisconnect(new Error("stale-alpha-failure")); + // Give the rejection every chance to be published before asserting it + // wasn't — a bare tick would pass even with the guard removed. + await waitUntil(() => + (r.lastFrame() ?? "").includes("stale-alpha-failure"), + ); + expect(r.lastFrame() ?? "").not.toContain("stale-alpha-failure"); + }); + it("switches tabs via accelerator keys", async () => { const r = await mount(stdioServer()); await press(r, ["t"]); // tools tab (server is auto-selected) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 0c779ab7..6e04f04e 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -170,6 +170,8 @@ function App({ pendingStepUpRef.current = pendingStepUp; }, [pendingStepUp]); const [connectError, setConnectError] = useState<string | null>(null); + // Monotonic token for in-flight disconnects — see handleDisconnect. + const disconnectAttemptRef = useRef(0); // A failed disconnect is deliberately NOT folded into `connectError`. That // one is only rendered by InfoTab when the status is "error", which a // rejected disconnect leaves untouched (the status stays "connected"), so @@ -769,7 +771,9 @@ function App({ if (!selectedServer || !selectedInspectorClient || !selectedServerConfig) { return; } - // A connect attempt supersedes whatever the last disconnect reported. + // A connect attempt supersedes whatever the last disconnect reported — + // including one still in flight, which is what the counter bump retires. + disconnectAttemptRef.current++; setDisconnectError(null); const finishConnect = async () => { @@ -901,6 +905,14 @@ function App({ // Disconnect handler const handleDisconnect = useCallback(async () => { if (!selectedServer) return; + // Claim this attempt before awaiting. A disconnect can still be in flight + // when the user switches servers or presses 'd' again, and it is the + // *stale* one that usually rejects — so the catch below must be able to + // tell "my failure" from "one that has since been superseded", or server + // A's error lands in server B's header. `handleConnect` bumps the same + // counter for the same reason. + const attempt = ++disconnectAttemptRef.current; + const attemptServer = selectedServer; // Clear first, so a retry that succeeds leaves no stale message behind. setDisconnectError(null); try { @@ -908,7 +920,14 @@ function App({ // InspectorClient will update status automatically, and data is preserved } catch (err) { // Nothing above this catches: the only caller is the key handler, which - // cannot await, so without this the rejection escapes unhandled. + // cannot await, so without this the rejection escapes unhandled. A + // superseded one is still owned here — just not published. + if ( + disconnectAttemptRef.current !== attempt || + selectedServerRef.current !== attemptServer + ) { + return; + } setDisconnectError(err instanceof Error ? err.message : String(err)); } }, [selectedServer, disconnectInspector]); diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 9d864ce2..fe465530 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -9,7 +9,6 @@ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { randomBytes } from "node:crypto"; -import open from "open"; import { serve } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import { Hono } from "hono"; @@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js"; import { webServerConfigToInitialPayload, printServerBanner, + openBrowser, } from "./web-server-config.js"; import type { WebServerHandle } from "./types.js"; @@ -125,13 +125,9 @@ export async function startHonoServer( sandboxUrl ?? undefined, ); if (config.autoOpen) { - // A headless or otherwise browser-less host rejects here. The server - // is already listening and the banner above printed the URL, so warn - // and carry on rather than letting the rejection escape this - // synchronous listen callback and take the process down. - open(url).catch((err: unknown) => { - console.warn("Could not open a browser automatically:", err); - }); + // Never a bare `open(url)`: this callback is synchronous, so a + // rejection would escape it. See openBrowser. + openBrowser(url); } }, ); diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index f07b1af6..880913e6 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -9,7 +9,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Plugin } from "vite"; -import open from "open"; // Direct leaf import — the `core/mcp/remote/node/index.ts` barrel re-exports // from `../constants.js`, which would otherwise drag the broader // `core/mcp/remote/index.ts` barrel (including `createRemoteLogger`, with its @@ -22,6 +21,7 @@ import type { WebServerConfig } from "./web-server-config.js"; import { webServerConfigToInitialPayload, printServerBanner, + openBrowser, } from "./web-server-config.js"; export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { @@ -114,12 +114,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { ); if (config.autoOpen) { - // Same as the prod server: a browser-less host rejects, the dev - // server is already up, and the banner above printed the URL — so - // warn rather than let the rejection escape this sync helper. - open(url).catch((err: unknown) => { - console.warn("Could not open a browser automatically:", err); - }); + // Never a bare `open(url)`: `logBanner` is synchronous, so a + // rejection would escape it. See openBrowser. + openBrowser(url); } }; diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 6e77bd28..6bb13be2 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -2,6 +2,7 @@ * Config object for the web server (dev and prod). Passed in-process; no env handoff. */ +import open from "open"; import pino from "pino"; import type { Logger } from "pino"; import type { MCPConfig, MCPServerConfig } from "../../../core/mcp/types.ts"; @@ -203,6 +204,24 @@ export function printServerBanner( return url; } +/** + * Launch the user's browser at `url`, reporting a failure rather than throwing. + * + * Both callers — the prod Hono server's `serve()` listen callback and the dev + * plugin's `logBanner` — are synchronous, so a rejection has nowhere to go and + * escapes as an unhandled rejection (#1959). On a headless or otherwise + * browser-less host that rejection is the ordinary outcome, not an edge case, + * and by the time it happens the server is already listening and + * `printServerBanner` has printed a perfectly usable URL. So warn and carry + * on. Shared between the two servers deliberately: two copies of a one-line + * `.catch` is two chances for one of them to drift back to a bare `open(url)`. + */ +export function openBrowser(url: string): void { + open(url).catch((err: unknown) => { + console.warn("Could not open a browser automatically:", err); + }); +} + export interface BuildWebServerConfigOptions { initialMcpConfig?: MCPServerConfig | null; /** Catalog/session file the backend should serve; see {@link WebServerConfig.mcpConfigPath}. */ diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 9a44f07c..427800a6 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -20,6 +20,7 @@ import { serve } from "@hono/node-server"; import type { ServerType } from "@hono/node-server"; import type pinoType from "pino"; import { + closeAbortedStream, createRemoteApp, requestIdForSendWait, mcpParamHeadersOnly, @@ -1107,4 +1108,37 @@ describe("server.ts supplemental coverage", () => { } }); }); + describe("closeAbortedStream", () => { + it("owns a rejected close instead of letting it go unhandled", async () => { + // The whole reason the helper exists. Both SSE `onAbort` listeners run + // after the peer is already gone, so `close()` can lose the race and + // reject — and Hono invokes abort subscribers with a bare + // `subscriber()`, so nothing upstream would catch it. A regression here + // does not fail at the call site; it fails the whole vitest run from + // wherever the rejection happens to surface. + const rejected = Promise.reject(new Error("peer already gone")); + const unhandled: unknown[] = []; + const onUnhandled = (err: unknown) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + try { + expect(closeAbortedStream({ close: () => rejected })).toBeUndefined(); + // An unhandledRejection fires on a later macrotask, not this one. + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + process.off("unhandledRejection", onUnhandled); + } + expect(unhandled).toEqual([]); + }); + + it("returns without waiting on a close that resolves", async () => { + let closed = false; + closeAbortedStream({ + close: async () => { + closed = true; + }, + }); + await Promise.resolve(); + expect(closed).toBe(true); + }); + }); }); diff --git a/clients/web/src/test/integration/server/server-auto-open.test.ts b/clients/web/src/test/integration/server/server-auto-open.test.ts new file mode 100644 index 00000000..527f8b56 --- /dev/null +++ b/clients/web/src/test/integration/server/server-auto-open.test.ts @@ -0,0 +1,149 @@ +/** + * `autoOpen` browser launch (#1959). + * + * Both servers call `openBrowser` from a synchronous callback — the prod + * server from `serve()`'s listen callback, the dev plugin from `logBanner` — + * so a rejection there has nowhere to go. On a headless or otherwise + * browser-less host that rejection is the *ordinary* outcome, not an edge + * case, and before #1959 it escaped as an unhandled rejection from a server + * that had already started listening and printed a perfectly usable URL. + * + * The helper is shared precisely so this behavior has one implementation to + * pin. The unit tests below cover it directly; the end-to-end test then + * proves the prod server routes through it and survives the failure. The dev + * plugin is not booted here — it returns early under `VITEST` by design (see + * `vite-hono-plugin.ts`) — but it calls the same tested helper. + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { createServer } from "node:net"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { WebServerConfig } from "../../../../server/web-server-config.js"; +import type { WebServerHandle } from "../../../../server/types.js"; + +const openMock = vi.hoisted(() => vi.fn()); +vi.mock("open", () => ({ default: openMock })); + +// Imported after the mock is registered so both bind to the double. +const { openBrowser } = await import("../../../../server/web-server-config.js"); +const { startHonoServer } = await import("../../../../server/server.js"); + +const WARNING = "Could not open a browser automatically:"; + +async function findFreePort(): Promise<number> { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.unref(); + srv.on("error", reject); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + if (addr && typeof addr === "object") { + const { port } = addr; + srv.close(() => resolve(port)); + } else { + srv.close(() => reject(new Error("Could not resolve a free port"))); + } + }); + }); +} + +/** An unhandledRejection fires on a later macrotask than the rejection. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +const INDEX_HTML = + "<!doctype html><html><body><div id=root></div></body></html>"; + +let handle: WebServerHandle | undefined; +let staticRoot: string | undefined; + +afterEach(async () => { + await handle?.close(); + handle = undefined; + if (staticRoot) await rm(staticRoot, { recursive: true, force: true }); + staticRoot = undefined; + openMock.mockReset(); + vi.restoreAllMocks(); +}); + +describe("openBrowser", () => { + it("owns a rejection and warns instead of letting it go unhandled", async () => { + // Suppress the expected warning rather than letting it pollute the run, + // but assert on it — the warning IS the user-visible half of the fix. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const unhandled: unknown[] = []; + const onUnhandled = (err: unknown) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + try { + openMock.mockRejectedValue(new Error("no browser on this host")); + expect(openBrowser("http://127.0.0.1:6274")).toBeUndefined(); + await settle(); + } finally { + process.off("unhandledRejection", onUnhandled); + } + expect(unhandled).toEqual([]); + expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error)); + }); + + it("stays quiet when the browser opens", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + openMock.mockResolvedValue(undefined); + openBrowser("http://127.0.0.1:6274"); + await settle(); + expect(openMock).toHaveBeenCalledWith("http://127.0.0.1:6274"); + expect(warn).not.toHaveBeenCalledWith(WARNING, expect.anything()); + }); +}); + +describe("startHonoServer autoOpen", () => { + it("keeps serving when the browser cannot be opened", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const unhandled: unknown[] = []; + const onUnhandled = (err: unknown) => unhandled.push(err); + process.on("unhandledRejection", onUnhandled); + + try { + openMock.mockRejectedValue(new Error("no browser on this host")); + + staticRoot = await mkdtemp(join(tmpdir(), "inspector-autoopen-")); + await writeFile(join(staticRoot, "index.html"), INDEX_HTML, "utf-8"); + const port = await findFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const config: WebServerConfig = { + port, + hostname: "127.0.0.1", + authToken: "auto-open-token", + dangerouslyOmitAuth: false, + initialMcpConfig: null, + mcpConfigPath: undefined, + writable: true, + initialServers: null, + storageDir: undefined, + allowedOrigins: [baseUrl], + sandboxPort: 0, + sandboxHost: "127.0.0.1", + logger: undefined, + autoOpen: true, + staticRoot, + }; + handle = await startHonoServer(config); + + // `serve()`'s listen callback fires after startHonoServer resolves. + for (let i = 0; i < 100 && openMock.mock.calls.length === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + await settle(); + + expect(openMock).toHaveBeenCalledWith(expect.stringContaining(baseUrl)); + expect(unhandled).toEqual([]); + expect(warn).toHaveBeenCalledWith(WARNING, expect.any(Error)); + // The point of warning rather than throwing: the server is still up. + const res = await fetch(`${baseUrl}/`); + expect(res.status).toBe(200); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 0a2408f9..9494b0f9 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -111,7 +111,7 @@ const SSE_PRIMING_COMMENT = ":\n\n"; * `async` listener into the same unhandled rejection one layer up. Swallowing * it here is the only place the rejection has an owner. */ -function closeAbortedStream(stream: { close(): Promise<void> }): void { +export function closeAbortedStream(stream: { close(): Promise<void> }): void { stream.close().catch(() => {}); } diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 1a119f96..4fb3447a 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -27,6 +27,7 @@ "core/**/*.ts", "core/**/*.tsx", "test-servers/src/**/*.ts", + "test-servers/src/**/*.tsx", "test-servers/src/**/*.mts", "test-servers/src/**/*.cts", "vitest.shared.mts" From d60a5c713d43d0f2af729ee11bb3484eff16c72d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 21:57:09 -0400 Subject: [PATCH 192/941] fix(web): only write settings on a real header edit; label rows by position (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review follow-ups on #2038. `buildHeaderSettingsPatch` treated the mere presence of stored headers as a change, so saving an id or URL edit re-sent the modal's settings snapshot — taken when it opened — and could overwrite a metadata or OAuth change made in the settings form since. It now compares the submitted headers against the stored ones and omits `settings` when they match, which is what makes the PUT route preserve the node. Order counts as a difference: it is what the form round-trips and what the user sees. Row labels carried the key alone, so two rows sharing a key — mid-edit, or a duplicate a server persisted — gave both rows' controls identical accessible names, the thing the labelling exists to prevent. The row number is now always part of the label rather than a blank-key fallback. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../KeyValueRows/KeyValueRows.stories.tsx | 6 +-- .../KeyValueRows/KeyValueRows.test.tsx | 41 ++++++++++++------ .../elements/KeyValueRows/KeyValueRows.tsx | 8 +++- .../ServerConfigModal.stories.tsx | 2 +- .../ServerConfigModal.test.tsx | 8 ++-- .../ServerSettingsForm.test.tsx | 12 ++++-- .../ServerSettingsModal.test.tsx | 12 ++++-- .../web/src/utils/serverSettingsPatch.test.ts | 42 +++++++++++++++++++ clients/web/src/utils/serverSettingsPatch.ts | 26 ++++++++---- 9 files changed, 121 insertions(+), 36 deletions(-) diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx index 05c5ca14..055a148b 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx @@ -27,18 +27,18 @@ export const Populated: Story = { play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const value = await canvas.findByRole("textbox", { - name: "header value, Cookie", + name: "header value, Cookie, row 1", }); await expect(value).toHaveValue("branch=feature-x"); await userEvent.click( - canvas.getByRole("button", { name: "Remove header, X-Env" }), + canvas.getByRole("button", { name: "Remove header, X-Env, row 2" }), ); await expect(args.onRemove).toHaveBeenCalledWith(1); }, }; -// A row whose key is still blank falls back to a positional name rather than +// A row whose key is still blank is named by position alone rather than // announcing nothing. export const BlankKeyRow: Story = { args: { items: [{ key: "", value: "" }] }, diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx index 09fceca7..60e6916d 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx @@ -30,12 +30,12 @@ describe("KeyValueRows", () => { { key: "", value: "" }, ]); expect( - screen.getByRole("textbox", { name: "header name, Cookie" }), + screen.getByRole("textbox", { name: "header name, Cookie, row 1" }), ).toHaveValue("Cookie"); expect( - screen.getByRole("textbox", { name: "header value, Cookie" }), + screen.getByRole("textbox", { name: "header value, Cookie, row 1" }), ).toHaveValue("a=1"); - // A key that is blank (or only whitespace) falls back to its position. + // A blank (or whitespace-only) key leaves the position alone as the name. expect( screen.getByRole("textbox", { name: "header name, row 2" }), ).toBeInTheDocument(); @@ -46,13 +46,13 @@ describe("KeyValueRows", () => { const { onChange } = setup([{ key: "X", value: "1" }]); await user.type( - screen.getByRole("textbox", { name: "header name, X" }), + screen.getByRole("textbox", { name: "header name, X, row 1" }), "Y", ); expect(onChange).toHaveBeenLastCalledWith(0, "XY", "1"); await user.type( - screen.getByRole("textbox", { name: "header value, X" }), + screen.getByRole("textbox", { name: "header value, X, row 1" }), "2", ); expect(onChange).toHaveBeenLastCalledWith(0, "X", "12"); @@ -65,12 +65,12 @@ describe("KeyValueRows", () => { // A bare "Clear" repeated per field would be indistinguishable across // rows, so each clear button names the field it empties. await user.click( - screen.getByRole("button", { name: "Clear header name, X" }), + screen.getByRole("button", { name: "Clear header name, X, row 1" }), ); expect(onChange).toHaveBeenLastCalledWith(0, "", "1"); await user.click( - screen.getByRole("button", { name: "Clear header value, X" }), + screen.getByRole("button", { name: "Clear header value, X, row 1" }), ); expect(onChange).toHaveBeenLastCalledWith(0, "X", ""); }); @@ -83,19 +83,34 @@ describe("KeyValueRows", () => { it("locks every control when disabled", () => { setup([{ key: "X", value: "1" }], true); expect( - screen.getByRole("textbox", { name: "header name, X" }), + screen.getByRole("textbox", { name: "header name, X, row 1" }), ).toBeDisabled(); expect( - screen.getByRole("textbox", { name: "header value, X" }), + screen.getByRole("textbox", { name: "header value, X, row 1" }), ).toBeDisabled(); expect( - screen.getByRole("button", { name: "Clear header name, X" }), + screen.getByRole("button", { name: "Clear header name, X, row 1" }), ).toBeDisabled(); expect( - screen.getByRole("button", { name: "Remove header, X" }), + screen.getByRole("button", { name: "Remove header, X, row 1" }), ).toBeDisabled(); }); + it("distinguishes two rows that share a key", () => { + // Duplicate keys happen mid-edit, and a server can persist duplicate + // metadata, so the key alone cannot identify a row. + setup([ + { key: "Set-Cookie", value: "a=1" }, + { key: "Set-Cookie", value: "b=2" }, + ]); + expect( + screen.getByRole("textbox", { name: "header value, Set-Cookie, row 1" }), + ).toHaveValue("a=1"); + expect( + screen.getByRole("textbox", { name: "header value, Set-Cookie, row 2" }), + ).toHaveValue("b=2"); + }); + it("removes the clicked row", async () => { const user = userEvent.setup({ delay: null }); const { onRemove } = setup([ @@ -103,7 +118,9 @@ describe("KeyValueRows", () => { { key: "B", value: "2" }, ]); - await user.click(screen.getByRole("button", { name: "Remove header, B" })); + await user.click( + screen.getByRole("button", { name: "Remove header, B, row 2" }), + ); expect(onRemove).toHaveBeenCalledWith(1); }); }); diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx index cf03e8e1..9a44585d 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx @@ -56,7 +56,13 @@ export function KeyValueRows({ return ( <> {items.map((item, index) => { - const rowLabel = item.key.trim() || `row ${index + 1}`; + // The row number is always part of the label, not just a fallback for + // a blank key: two rows can carry the SAME key (mid-edit, or a + // duplicate a server legitimately persisted), and a key-only label + // would give both rows' controls identical accessible names — the very + // thing this labelling exists to prevent. + const key = item.key.trim(); + const rowLabel = key ? `${key}, row ${index + 1}` : `row ${index + 1}`; return ( <Group key={index} grow> <ClearableTextInput diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx index 0b990f43..d44cd75e 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx @@ -159,7 +159,7 @@ export const EditSseWithHeaders: Story = { const body = within(canvasElement.ownerDocument.body); const dialog = within(await findDialog(body, "Edit server")); const value = (await dialog.findByRole("textbox", { - name: "header value, Cookie", + name: "header value, Cookie, row 1", })) as HTMLInputElement; await expect(value.value).toBe("branch=feature-x"); diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index 9bd373ef..0a3416c6 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -509,7 +509,7 @@ describe("ServerConfigModal", () => { "Cookie", ); await user.type( - screen.getByRole("textbox", { name: /header value, Cookie/ }), + screen.getByRole("textbox", { name: /header value, Cookie, row 1/ }), "branch=feature-x", ); await user.click(screen.getByRole("button", { name: /^Add$/ })); @@ -538,7 +538,7 @@ describe("ServerConfigModal", () => { ); const valueInput = screen.getByRole("textbox", { - name: /header value, X-Env/, + name: /header value, X-Env, row 1/, }); expect(valueInput).toHaveValue("dev"); await user.type(valueInput, "-2"); @@ -573,11 +573,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: "+ Add Header" })); // Editing one row of several must leave its siblings untouched. await user.type( - screen.getByRole("textbox", { name: "header value, Keep" }), + screen.getByRole("textbox", { name: "header value, Keep, row 1" }), "9", ); await user.click( - screen.getByRole("button", { name: "Remove header, Drop" }), + screen.getByRole("button", { name: "Remove header, Drop, row 2" }), ); await user.click(screen.getByRole("button", { name: /^Save$/ })); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 8bc001c7..d313c00a 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -10,7 +10,7 @@ import { /** Find the clear button living in the rightSection of `input`'s field. The * name is a prefix match because a KeyValueRows field names its clear button - * for the row it belongs to ("Clear header name, Cookie"), while a standalone + * for the row it belongs to ("Clear header name, Cookie, row 1"), while a standalone * field keeps the bare "Clear". */ function clearButtonFor(input: HTMLElement): HTMLElement { const root = @@ -333,7 +333,9 @@ describe("ServerSettingsForm", () => { />, ); await user.click( - screen.getByRole("button", { name: "Remove header, Authorization" }), + screen.getByRole("button", { + name: "Remove header, Authorization, row 1", + }), ); expect(onRemoveHeader).toHaveBeenCalledWith(0); }); @@ -356,7 +358,9 @@ describe("ServerSettingsForm", () => { expect(onMetadataChange).toHaveBeenCalled(); await user.click( - screen.getByRole("button", { name: "Remove metadata entry, userId" }), + screen.getByRole("button", { + name: "Remove metadata entry, userId, row 1", + }), ); expect(onRemoveMetadata).toHaveBeenCalledWith(0); }); @@ -685,7 +689,7 @@ describe("ServerSettingsForm", () => { // accessible name identifies which row it belongs to. await user.click( screen.getByRole("button", { - name: "Remove environment variable, API_KEY", + name: "Remove environment variable, API_KEY, row 1", }), ); expect(onRemoveEnv).toHaveBeenCalledWith(0); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 8c29c418..a5dbcb0d 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -277,7 +277,9 @@ describe("ServerSettingsModal", () => { ); await user.click(screen.getByRole("button", { name: "Custom Headers" })); await user.click( - screen.getByRole("button", { name: "Remove header, Authorization" }), + screen.getByRole("button", { + name: "Remove header, Authorization, row 1", + }), ); expect(onSettingsChange).toHaveBeenCalledWith({ ...initialSettings, @@ -346,7 +348,9 @@ describe("ServerSettingsModal", () => { // Both the header and metadata rows have a remove button; each is named // for the row it belongs to, so no positional guess is needed. await user.click( - screen.getByRole("button", { name: "Remove metadata entry, userId" }), + screen.getByRole("button", { + name: "Remove metadata entry, userId, row 1", + }), ); expect(onSettingsChange).toHaveBeenCalledWith({ ...initialSettings, @@ -767,7 +771,9 @@ describe("ServerSettingsModal", () => { screen.getByRole("button", { name: "Environment Variables" }), ); await user.click( - screen.getByRole("button", { name: "Remove environment variable, A" }), + screen.getByRole("button", { + name: "Remove environment variable, A, row 1", + }), ); expect(onSettingsChange).toHaveBeenCalledWith({ ...emptySettings, diff --git a/clients/web/src/utils/serverSettingsPatch.test.ts b/clients/web/src/utils/serverSettingsPatch.test.ts index 12d7c081..06c892cf 100644 --- a/clients/web/src/utils/serverSettingsPatch.test.ts +++ b/clients/web/src/utils/serverSettingsPatch.test.ts @@ -52,6 +52,48 @@ describe("buildHeaderSettingsPatch", () => { expect(patch?.connectionTimeout).toBe(5000); }); + it("sends nothing when an edit leaves the headers untouched", () => { + // The modal holds a snapshot taken when it opened, so writing it back on + // an id/URL-only save would clobber a metadata or OAuth change made in the + // settings form since. Omitting the key is what preserves it. + expect( + buildHeaderSettingsPatch( + "edit", + POPULATED, + // A fresh array with equal contents — the modal always rebuilds it. + [{ key: "X-Old", value: "1" }], + EMPTY, + ), + ).toBeUndefined(); + }); + + it("sends when an edit only reorders the headers", () => { + const two: InspectorServerSettings = { + ...POPULATED, + headers: [ + { key: "A", value: "1" }, + { key: "B", value: "2" }, + ], + }; + expect( + buildHeaderSettingsPatch( + "edit", + two, + [ + { key: "B", value: "2" }, + { key: "A", value: "1" }, + ], + EMPTY, + ), + ).toEqual({ + ...two, + headers: [ + { key: "B", value: "2" }, + { key: "A", value: "1" }, + ], + }); + }); + it("still sends on an edit that clears the last header", () => { expect(buildHeaderSettingsPatch("edit", POPULATED, [], EMPTY)).toEqual({ ...POPULATED, diff --git a/clients/web/src/utils/serverSettingsPatch.ts b/clients/web/src/utils/serverSettingsPatch.ts index b2b51a81..25fe7709 100644 --- a/clients/web/src/utils/serverSettingsPatch.ts +++ b/clients/web/src/utils/serverSettingsPatch.ts @@ -15,11 +15,14 @@ import type { KeyValuePair } from "../components/elements/KeyValueRows/KeyValueR * the modal's target in clone mode is the *source* server, so spreading it * would copy that server's OAuth client secret and behavior flags onto a * new entry the user only gave a URL and some headers. - * 2. **`undefined` means "don't send the key at all."** Omitting `settings` - * is what makes the backend preserve the node it already has, so with no - * headers on either side there is nothing to write and nothing to clear. - * Clearing the last header still sends — `existing` had headers, so the - * node must be rewritten without them. + * 2. **`undefined` means "don't send the key at all", and that is the + * default.** Omitting `settings` makes the backend preserve the node it + * already has, which matters beyond convenience: what this modal holds is + * a snapshot taken when it opened, so writing it back on a save that did + * not touch a header would overwrite a metadata or OAuth change made in + * the settings form in the meantime. So the patch is sent only when the + * submitted headers actually differ from the stored ones — including the + * case where the last one was cleared, which does need the node rewritten. * * Lives here rather than inline in App.tsx so this seam is unit-testable: * App.tsx is outside the coverage gate, and the edit-vs-clone distinction is @@ -35,8 +38,15 @@ export function buildHeaderSettingsPatch( emptySettings: InspectorServerSettings, ): InspectorServerSettings | undefined { const existing = mode === "edit" ? existingSettings : undefined; - const changesSomething = - headers.length > 0 || (existing?.headers.length ?? 0) > 0; - if (!changesSomething) return undefined; + if (sameHeaders(existing?.headers ?? [], headers)) return undefined; return { ...(existing ?? emptySettings), headers }; } + +/** + * Order-sensitive pair-list equality. Order matters because it is what the + * form round-trips and what the user sees, so a reorder is a real edit. + */ +function sameHeaders(a: KeyValuePair[], b: KeyValuePair[]): boolean { + if (a.length !== b.length) return false; + return a.every((h, i) => h.key === b[i]?.key && h.value === b[i]?.value); +} From 1939136effaca7dc5b46b61556e8d22e9fd4653d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:02:39 -0400 Subject: [PATCH 193/941] fix: read template variables as own properties only Addresses Copilot's round-6 review on #2035. `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570 variable names -- `varname` allows ALPHA / DIGIT / `_` / pct-encoded -- and every lookup here was a bare `values[name]`, which finds `Object.prototype`'s member for each of them. Measured: `({})["toString"] !== undefined` is true with typeof "function", and `({}["constructor"] ?? "").length` is 1. So a *blank* `{?toString}` field read as supplied and expanded a function body into the URI instead of omitting the expression, and `hasRequiredValues([["constructor"]], {})` returned true because `Object.length` is 1 -- a required group satisfied by nothing. All reads now go through an own-property `readValue`, and the web preview's filled-check uses `Object.hasOwn`. `expandExpression` carries the value through its filter rather than re-reading it afterwards, so the "is it defined" test and the read cannot disagree and nothing needs a non-null assertion. The remaining six findings were documentation drifting behind the code, which matters here because the comments are the argument for why this module exists: - The web helper still described the SDK's second-`?`-to-`&` rewrite as something the preview relies on. That was removed last round. - Three places still named the CLI as a consumer. It has no template form and passes an already-expanded `--uri` to `readResource`. - The PR description still described the superseded architecture (expansion delegated to the SDK), i.e. the opposite of what now ships. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 2 +- .../web/src/test/core/mcp/uriTemplate.test.ts | 38 +++++++++++++++++++ clients/web/src/utils/uriTemplate.test.ts | 16 ++++++++ clients/web/src/utils/uriTemplate.ts | 33 +++++++++------- core/mcp/uriTemplate.ts | 36 ++++++++++++++---- 5 files changed, 102 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c9f662e5..c7fa48e0 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ inspector/ │ │ # schema collapse shared by the web and TUI form builders │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import, -│ │ # and the RFC 6570 URI-template helpers all three clients expand through +│ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through │ ├── node/ # Node-only shared helpers: version reader, hostUrl (host normalize/canonicalize + all-interfaces/loopback detection) │ ├── react/ # React hooks over the state stores │ └── storage/ # File I/O helpers for the OAuth persist backends diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 4b847131..f9bfb9b1 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -468,3 +468,41 @@ describe("prefix-modifier grammar", () => { ); }); }); + +describe("variable names that collide with Object.prototype", () => { + // `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570 + // varnames. A bare `values[name]` lookup finds the prototype's member for + // every one of them, so a *blank* field read as supplied: measured, + // `({})["toString"] !== undefined` is true and its typeof is "function". + it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])( + "omits a blank {?%s} instead of expanding a prototype member", + (name) => { + expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe( + "x://a", + ); + }, + ); + + it("omits the expression when the key is absent entirely", () => { + expect(expandUriTemplate("x://a{?toString}", {})).toBe("x://a"); + }); + + it("still expands such a variable when it really has a value", () => { + expect(expandUriTemplate("x://a{?toString}", { toString: "v" })).toBe( + "x://a?toString=v", + ); + }); + + it("does not treat an inherited member as satisfying a required group", () => { + // `Object` (the inherited constructor) has length 1, so the old + // `(values[name] ?? "").length > 0` test reported this as satisfied. + expect(hasRequiredValues([["constructor"]], {})).toBe(false); + expect(hasRequiredValues([["constructor"]], { constructor: "c" })).toBe( + true, + ); + }); + + it("handles __proto__ as an ordinary variable name", () => { + expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a"); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index feba12fe..3e9ca93b 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -63,3 +63,19 @@ describe("previewUriTemplate - multi-name expressions", () => { ); }); }); + +describe("preview with Object.prototype-colliding names", () => { + it("leaves a blank {?toString} standing rather than treating it as filled", () => { + // A bare `defined[name] !== undefined` finds Object.prototype.toString and + // would expand the expression instead of showing the placeholder. + expect(previewUriTemplate("x://a{?toString}", { toString: "" })).toBe( + "x://a{?toString}", + ); + }); + + it("expands it once it really has a value", () => { + expect(previewUriTemplate("x://a{?toString}", { toString: "v" })).toBe( + "x://a?toString=v", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 56b2b825..ea6c089b 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -2,10 +2,12 @@ * The web Resources form's view of an RFC 6570 URI template (#1919). * * Parsing, variable classification, and expansion live in - * `@inspector/core/mcp/uriTemplate.js` so the web form, the TUI, and the CLI - * cannot disagree about what a template means -- they are re-exported here so - * the panel has a single import. What this module adds is the one piece that is - * purely a display concern: the partially-expanded preview string. + * `@inspector/core/mcp/uriTemplate.js` so the web form and the TUI cannot + * disagree about what a template means -- they are re-exported here so the + * panel has a single import. (The CLI is not a consumer: it has no template + * form, and its `resources/read` passes an already-expanded `--uri` straight to + * `readResource`.) What this module adds is the one piece that is purely a + * display concern: the partially-expanded preview string. */ import { @@ -37,16 +39,16 @@ export type { const deferredToken = (index: number) => `\u0000${index}\u0000`; /** - * A partially-expanded template for display: expressions whose variables are - * all filled are expanded exactly as `expandUriTemplate` would, and the rest - * are left standing as written so the user can see what is still needed. + * A partially-expanded template for display: expressions with at least one + * value are expanded exactly as `expandUriTemplate` would, and the rest are + * left standing as written so the user can see what is still needed. * - * Unfilled expressions are swapped for an inert token and restored after - * expansion -- rather than expanding each filled expression in isolation -- so - * the expander still sees one whole template and applies its cross-expression - * rules (notably rewriting a second `?` query expression to `&`). Routing the - * rewritten template back through `expandUriTemplate` is what keeps the preview - * honest: it can never promise a URI that submitting would not send. + * Unfilled expressions are swapped for an inert token and restored afterwards, + * and the rewritten template is expanded by `expandUriTemplate` itself. Routing + * it back through the real expander is what keeps the preview honest: it cannot + * promise a URI that submitting would not send. (Expansion carries no + * cross-expression state — see `expandParts` — so this is a per-expression + * substitution, not a whole-template rewrite that some later pass depends on.) */ export function previewUriTemplate( uriTemplate: string, @@ -59,7 +61,10 @@ export function previewUriTemplate( const rewritten = parts .map((part) => { if (part.kind === "literal") return part.text; - if (part.names.some((name) => defined[name] !== undefined)) { + // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are + // valid RFC 6570 variable names, and a plain lookup would find + // `Object.prototype`'s member and treat a blank field as filled. + if (part.names.some((name) => Object.hasOwn(defined, name))) { return part.source; } deferred.push(part.source); diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index b68a5c42..7e5c1d87 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -257,10 +257,27 @@ export function hasRequiredValues( values: Record<string, string>, ): boolean { return groups.every((names) => - names.some((name) => (values[name] ?? "").length > 0), + names.some((name) => (readValue(values, name) ?? "").length > 0), ); } +/** + * Reads a variable, ignoring anything inherited from `Object.prototype`. + * + * `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570 + * variable names (`varname` allows ALPHA / DIGIT / `_` / pct-encoded), and a + * plain object lookup finds the prototype's member for every one of them. A + * bare `values[name] !== undefined` therefore reports a *blank* `{?toString}` + * as supplied and expands a function body into the URI; `hasRequiredValues` + * likewise saw `constructor` as satisfied because `Object.length` is 1. + */ +function readValue( + values: Record<string, string>, + name: string, +): string | undefined { + return Object.hasOwn(values, name) ? values[name] : undefined; +} + /** * Drops empty entries so an untouched optional field reads as *undefined* * (the expression disappears) rather than as the empty string (which would @@ -361,17 +378,20 @@ function expandExpression( part: TemplateExpression, values: Record<string, string>, ): string { - const present = part.varspecs.filter( - (spec) => values[spec.name] !== undefined, - ); + // The value is carried through the filter rather than re-read afterwards, so + // the "is it defined" test and the read cannot disagree — and so nothing + // downstream needs a non-null assertion to convince the compiler. + const present = part.varspecs.flatMap((spec) => { + const value = readValue(values, spec.name); + return value === undefined ? [] : [{ spec, value }]; + }); if (present.length === 0) return ""; const { operator } = part; if (NAMED_OPERATORS.has(operator)) { const pairs = present.map( - (spec) => - `${spec.name}=${renderValue(values[spec.name], spec, operator)}`, + ({ spec, value }) => `${spec.name}=${renderValue(value, spec, operator)}`, ); // `;` repeats its separator per pair; `?`/`&` join with `&`. return operator === ";" @@ -379,8 +399,8 @@ function expandExpression( : `${operator}${pairs.join("&")}`; } - const rendered = present.map((spec) => - renderValue(values[spec.name], spec, operator), + const rendered = present.map(({ spec, value }) => + renderValue(value, spec, operator), ); switch (operator) { From 8d6803953c161649deb971290cb9e3dc3c6342da Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:11:16 -0400 Subject: [PATCH 194/941] perf(auth): gate the override wrapper on the discovery request, and guard disk values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two suppressed review findings. With an override configured, the wrapper cloned and parsed every successful application/json response — every tools/call result and every resource payload on the direct CLI/TUI transports — only to conclude it was not a metadata document. It now tests the request URL for the RFC 8414 / OIDC well-known paths first, so ordinary traffic costs one string test and is returned untouched. The read path also trusted StoredMCPServer's `string` type for the two override fields, which a hand-edited mcp.json does not honor: the CLI and TUI read the file directly and then `.trim()` these, so a number would crash the server load. Guarded with cleanEndpointOverride, mirroring cleanAuthorizationParams. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../test/core/auth/endpointOverrides.test.ts | 64 +++++++++++++++---- .../web/src/test/core/mcp/serverList.test.ts | 23 +++++++ core/auth/endpointOverrides.ts | 48 ++++++++++++++ core/mcp/serverList.ts | 45 +++++++++++-- 4 files changed, 162 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/auth/endpointOverrides.test.ts b/clients/web/src/test/core/auth/endpointOverrides.test.ts index f44a4afc..19fbe266 100644 --- a/clients/web/src/test/core/auth/endpointOverrides.test.ts +++ b/clients/web/src/test/core/auth/endpointOverrides.test.ts @@ -14,6 +14,8 @@ const METADATA = { response_types_supported: ["code"], }; +const AS_METADATA_URL = + "https://as.example.com/.well-known/oauth-authorization-server"; const STAGING_AUTHORIZE = "https://staging.example.com/authorize"; const STAGING_TOKEN = "https://staging.example.com/token"; @@ -190,7 +192,7 @@ describe("withOAuthEndpointOverrides", () => { tokenUrl: STAGING_TOKEN, })); - const response = await wrapped("https://as.example.com/.well-known/x"); + const response = await wrapped(AS_METADATA_URL); await expect(response.json()).resolves.toEqual({ ...METADATA, authorization_endpoint: STAGING_AUTHORIZE, @@ -206,18 +208,52 @@ describe("withOAuthEndpointOverrides", () => { const config: { overrides?: { tokenUrl?: string } } = {}; const wrapped = withOAuthEndpointOverrides(base, () => config.overrides); - const before = await wrapped("https://as.example.com/.well-known/x"); + const before = await wrapped(AS_METADATA_URL); await expect(before.json()).resolves.toMatchObject({ token_endpoint: METADATA.token_endpoint, }); config.overrides = { tokenUrl: STAGING_TOKEN }; - const after = await wrapped("https://as.example.com/.well-known/x"); + const after = await wrapped(AS_METADATA_URL); await expect(after.json()).resolves.toMatchObject({ token_endpoint: STAGING_TOKEN, }); }); + // The gate that keeps this wrapper off the hot path: an ordinary JSON-RPC + // response is not even cloned, let alone parsed. Regression test for PR #2037. + it("ignores a response to a request that is not metadata discovery", async () => { + const original = jsonResponse(METADATA); + const clone = vi.spyOn(original, "clone"); + const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ + tokenUrl: STAGING_TOKEN, + })); + + await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe( + original, + ); + expect(clone).not.toHaveBeenCalled(); + }); + + it("recognizes discovery through every request-input form", async () => { + const wrapped = withOAuthEndpointOverrides( + // A fresh response per call: each patched one consumes its own clone. + async () => jsonResponse(METADATA), + () => ({ tokenUrl: STAGING_TOKEN }), + ); + const oidcUrl = "https://as.example.com/.well-known/openid-configuration"; + for (const input of [ + AS_METADATA_URL, + new URL(oidcUrl), + new Request(`${AS_METADATA_URL}/tenant-a`), + ]) { + const response = await wrapped(input); + await expect(response.json()).resolves.toMatchObject({ + token_endpoint: STAGING_TOKEN, + }); + } + }); + it("warns once about a malformed override, not on every request", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const base: typeof fetch = async () => jsonResponse(METADATA); @@ -225,9 +261,9 @@ describe("withOAuthEndpointOverrides", () => { tokenUrl: "not a url", })); - await wrapped("https://as.example.com/x"); - await wrapped("https://as.example.com/x"); - await wrapped("https://as.example.com/x"); + await wrapped(AS_METADATA_URL); + await wrapped(AS_METADATA_URL); + await wrapped(AS_METADATA_URL); expect(warn).toHaveBeenCalledTimes(1); }); @@ -238,7 +274,7 @@ describe("withOAuthEndpointOverrides", () => { passThrough(original), () => undefined, ); - await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original); }); it("leaves an error response alone", async () => { @@ -246,7 +282,7 @@ describe("withOAuthEndpointOverrides", () => { const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ tokenUrl: STAGING_TOKEN, })); - await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original); }); it("leaves a non-JSON response alone", async () => { @@ -256,7 +292,7 @@ describe("withOAuthEndpointOverrides", () => { const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ tokenUrl: STAGING_TOKEN, })); - await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original); }); // Everything that is not a metadata document comes back as the caller's own @@ -267,7 +303,7 @@ describe("withOAuthEndpointOverrides", () => { const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ tokenUrl: STAGING_TOKEN, })); - const response = await wrapped("https://as.example.com/token"); + const response = await wrapped(AS_METADATA_URL); expect(response).toBe(original); await expect(response.json()).resolves.toEqual({ access_token: "abc" }); }); @@ -277,7 +313,7 @@ describe("withOAuthEndpointOverrides", () => { const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ tokenUrl: STAGING_TOKEN, })); - const response = await wrapped("https://as.example.com/x"); + const response = await wrapped(AS_METADATA_URL); expect(response).toBe(original); await expect(response.text()).resolves.toBe("not json at all"); }); @@ -316,7 +352,7 @@ describe("withOAuthEndpointOverrides", () => { ), () => ({ tokenUrl: STAGING_TOKEN }), ); - const response = await wrapped("https://as.example.com/x"); + const response = await wrapped(AS_METADATA_URL); await expect(response.json()).resolves.toMatchObject({ token_endpoint: STAGING_TOKEN, }); @@ -333,7 +369,7 @@ describe("withOAuthEndpointOverrides", () => { const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({ tokenUrl: STAGING_TOKEN, })); - await expect(wrapped("https://as.example.com/x")).resolves.toBe(original); + await expect(wrapped(AS_METADATA_URL)).resolves.toBe(original); }); it("drops the stale content-length and content-encoding of a rewritten body", async () => { @@ -350,7 +386,7 @@ describe("withOAuthEndpointOverrides", () => { ), () => ({ tokenUrl: STAGING_TOKEN }), ); - const response = await wrapped("https://as.example.com/x"); + const response = await wrapped(AS_METADATA_URL); expect(response.headers.get("content-length")).toBeNull(); expect(response.headers.get("content-encoding")).toBeNull(); expect(response.headers.get("content-type")).toBe("application/json"); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 09e4119c..0492622d 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -1304,6 +1304,29 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => { expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token"); }); + // Hand-edited `mcp.json` reaches the CLI/TUI unvalidated, and + // `oauthEndpointOverridesFromSettings` calls `.trim()` on these — so a + // non-string would crash the server load rather than being ignored. + it("drops a non-string endpoint override from disk", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const settings = storedFieldsToInspectorSettings({ + oauth: { + clientId: "cid", + // Types the field as a string; disk does not honor that. + authorizationUrl: 42 as unknown as string, + tokenUrl: "https://staging.example.com/token", + }, + }); + expect(settings?.oauthAuthorizationUrl).toBeUndefined(); + expect(settings?.oauthTokenUrl).toBe("https://staging.example.com/token"); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("oauth.authorizationUrl"), + ); + // Restored here rather than left to a global teardown: a later describe + // spies on `console.warn` again and would inherit this spy's recorded call. + warn.mockRestore(); + }); + it("reads a blank endpoint override back as unset", () => { const settings = storedFieldsToInspectorSettings({ oauth: { clientId: "cid", authorizationUrl: "", tokenUrl: "" }, diff --git a/core/auth/endpointOverrides.ts b/core/auth/endpointOverrides.ts index e0e01ae6..c2438b30 100644 --- a/core/auth/endpointOverrides.ts +++ b/core/auth/endpointOverrides.ts @@ -196,6 +196,51 @@ export function applyOAuthEndpointOverrides( return patched; } +/** + * The well-known paths authorization-server metadata is discovered at: RFC 8414 + * (§3.1, including the path-suffixed and path-prefixed variants the SDK's + * `buildDiscoveryUrls` emits) and OpenID Connect Discovery. Matched as a + * substring of the pathname so every variant is covered by two literals. + */ +const AS_METADATA_WELL_KNOWN_PATHS = [ + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", +] as const; + +/** The request URL a `fetch` call was made with, in any of its three forms. */ +function requestUrlOf(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +/** + * Whether a request is an authorization-server metadata discovery request. + * + * This is what keeps the wrapper off the hot path. Without it, every successful + * `application/json` response on the connection — every `tools/call` result, and + * every resource payload on the direct CLI/TUI transports — would be buffered + * and parsed a second time before the SDK could consume it, purely to discover + * that it is not a metadata document. Gating on the discovery URL first means + * ordinary traffic is untouched: one string test and the response is returned. + * + * A URL that will not parse falls back to testing the raw string, which is the + * conservative direction — worst case a request is *considered*, and the + * metadata predicate then rejects it. + */ +function isAuthorizationServerMetadataRequest( + input: RequestInfo | URL, +): boolean { + const raw = requestUrlOf(input); + let pathname: string; + try { + pathname = new URL(raw).pathname; + } catch { + pathname = raw; + } + return AS_METADATA_WELL_KNOWN_PATHS.some((path) => pathname.includes(path)); +} + /** * Whether a `content-type` names a **whole JSON document** — something that can * be read to completion. @@ -268,6 +313,9 @@ export function withOAuthEndpointOverrides( const response = await fetchFn(input, init); const overrides = resolveOverrides(); if (!overrides) return response; + // Cheapest tests first: only a discovery request can carry the document + // this wrapper rewrites, so ordinary traffic never reaches the clone below. + if (!isAuthorizationServerMetadataRequest(input)) return response; if (!response.ok) return response; if (!isJsonDocumentResponse(response.headers.get("content-type"))) return response; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index c29ba9e6..f4be5f4a 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -248,6 +248,33 @@ export function oauthAuthorizationParamsFromSettings( return Object.keys(record).length > 0 ? record : undefined; } +/** + * Validate one of a server's `oauth` endpoint overrides as it comes off disk, + * or `undefined` when there is nothing usable. (#1906) + * + * Same reasoning as `cleanAuthorizationParams` above: `StoredMCPServer` types + * these as `string`, but that is a compile-time promise a hand-edited file does + * not keep, and the CLI/TUI read `mcp.json` directly. A non-string reaches + * `oauthEndpointOverridesFromSettings`, which calls `.trim()` on it — so without + * this the whole server list fails to load. URL *validity* is not checked here; + * that happens where the override is applied, so a typo drops one field with a + * warning instead of anything louder. + */ +export function cleanEndpointOverride( + value: string | undefined, + field: "authorizationUrl" | "tokenUrl", +): string | undefined { + if (value === undefined) return undefined; + // Keep: unreachable per the parameter type, reachable from disk. + if (typeof value !== "string") { + console.warn( + `Ignoring \`oauth.${field}\`: expected a string, got ${typeof value}.`, + ); + return undefined; + } + return value.trim() || undefined; +} + /** * Collapse a server's endpoint-override fields into the * `InspectorClientOptions.oauth` sub-shape, or `undefined` when neither is set. @@ -381,10 +408,20 @@ export function storedFieldsToInspectorSettings( settings.oauthAuthorizationParams = envRecordToPairs(authorizationParams); } // Truthiness drops empty strings like the credential fields above, so a - // cleared field reads back as unset rather than as an empty override. (#1906) - if (stored.oauth?.authorizationUrl) - settings.oauthAuthorizationUrl = stored.oauth.authorizationUrl; - if (stored.oauth?.tokenUrl) settings.oauthTokenUrl = stored.oauth.tokenUrl; + // cleared field reads back as unset rather than as an empty override. The + // `string` type is a compile-time promise a hand-edited `mcp.json` does not + // keep, and only the web client's `/api/servers` route checks it — the CLI and + // TUI read the file directly and then call `.trim()` on these in + // `oauthEndpointOverridesFromSettings`, so a non-string would crash the server + // load. Sanitized like `authorizationParams` above rather than trusted. + // (#1906) + const authorizationUrl = cleanEndpointOverride( + stored.oauth?.authorizationUrl, + "authorizationUrl", + ); + if (authorizationUrl) settings.oauthAuthorizationUrl = authorizationUrl; + const tokenUrl = cleanEndpointOverride(stored.oauth?.tokenUrl, "tokenUrl"); + if (tokenUrl) settings.oauthTokenUrl = tokenUrl; if (stored.oauth?.onInsufficientScope) { settings.oauthOnInsufficientScope = stored.oauth.onInsufficientScope; } From 85e3215534a203a74e4225f599388632a52cead6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:11:40 -0400 Subject: [PATCH 195/941] fix(tui): retire the disconnect token on a server switch, not just clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot round 3 (PR #2039): clearing the banner when `selectedServer` changes is not enough. Start disconnecting A, switch A → B → A, and by the time the original request rejects both guards match again — the attempt token is unchanged and the selected server is A once more — so the stale failure lands on the freshly re-selected A. Bump the token on every switch, so a round trip invalidates prior disconnects the same way a one-way switch does. New test drives exactly that interleaving; it fails without the bump (the one-way test does not, which is why this needed its own). Also attaches the before/after TUI screenshots the review asked for — the review was right that "no UI change" stopped being true once the banner was added — and corrects the PR description accordingly. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/tui/__tests__/App.test.tsx | 24 ++++++++++++++++++++++++ clients/tui/src/App.tsx | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 4f7a25c8..11b65af5 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -722,6 +722,30 @@ describe("App (foundation)", () => { expect(r.lastFrame() ?? "").not.toContain("stale-alpha-failure"); }); + it("drops a stale disconnect rejection across an A → B → A round trip", async () => { + // The server-name check alone passes here: by the time the rejection + // lands, alpha is selected again. Only retiring the attempt token on + // every switch catches it. + h.ctrl.status = "connected"; + let rejectDisconnect: (err: Error) => void = () => {}; + h.disconnect.mockImplementationOnce( + () => + new Promise<void>((_resolve, reject) => { + rejectDisconnect = reject; + }), + ); + const r = await mount(stdioServer()); + r.stdin.write("d"); // alpha's disconnect starts, and hangs + await tick(); + await press(r, [DOWN]); // alpha -> beta + await expectFrame(r, "b.js"); + await press(r, [UP]); // beta -> alpha again + await expectFrame(r, "s.js"); + rejectDisconnect(new Error("round-trip-failure")); + await waitUntil(() => (r.lastFrame() ?? "").includes("round-trip-failure")); + expect(r.lastFrame() ?? "").not.toContain("round-trip-failure"); + }); + it("switches tabs via accelerator keys", async () => { const r = await mount(stdioServer()); await press(r, ["t"]); // tools tab (server is auto-selected) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 6e04f04e..240bbd82 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -446,7 +446,11 @@ function App({ setOauthStatus("idle"); setOauthMessage(null); // The header banner is server-scoped, so a failure from the server we just - // left must not be read as this one's. + // left must not be read as this one's. Clearing is not enough: a switch + // away and back (A → B → A) would leave both the attempt token and the + // server name matching again, so the stale rejection would land on the + // re-selected A. Retiring the token on every switch is what closes that. + disconnectAttemptRef.current++; setDisconnectError(null); const stepUp = pendingStepUpRef.current; if (stepUp && selectedServer && stepUp.serverName !== selectedServer) { From 51d35d864b97497b80a7b54b2227724cd25abff3 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:14:40 -0400 Subject: [PATCH 196/941] fix(web): correct the OAuth header hint, keep utils off components (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review follow-ups on #2038. The Custom Headers hint claimed a custom `Authorization` value is "ignored" once OAuth is configured. Verified against the installed @modelcontextprotocol/client: both SSE and streamable-HTTP `_commonHeaders()` set the bearer first and then spread `requestInit.headers`, so a custom value *overrides* the OAuth token. The hint told users the opposite of what would happen, and could have them silently break their own auth. Corrected in both places — the modal's new hint and the pre-existing one in ServerSettingsForm, which carried the same wrong claim. `utils/serverSettingsPatch.ts` imported both its shared types from components, reversing the one-way `components -> utils` direction AGENTS.md sets. `KeyValuePair` moves to `utils/keyValuePairs.ts` (KeyValueRows re-exports it), and the mode is spelled as a literal union — still checked, since `ServerConfigModalMode` must stay assignable to it at the call site. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../elements/KeyValueRows/KeyValueRows.tsx | 8 ++++---- .../ServerConfigModal/ServerConfigModal.tsx | 6 +++--- .../ServerSettingsForm/ServerSettingsForm.tsx | 8 +++++--- clients/web/src/utils/keyValuePairs.ts | 15 +++++++++++++++ clients/web/src/utils/serverSettingsPatch.ts | 9 ++++++--- 5 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 clients/web/src/utils/keyValuePairs.ts diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx index 9a44585d..776ce5e9 100644 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx +++ b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx @@ -1,10 +1,10 @@ import { ActionIcon, Group, TextInput } from "@mantine/core"; import { ClearButton } from "../ClearButton/ClearButton"; +import type { KeyValuePair } from "../../../utils/keyValuePairs"; -export interface KeyValuePair { - key: string; - value: string; -} +// Re-exported for call sites that already import this component, so they need +// not know the type lives a layer down. +export type { KeyValuePair }; export interface KeyValueRowsProps { items: KeyValuePair[]; diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index 0361aa32..105bb121 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -497,9 +497,9 @@ export function ServerConfigModal({ </HeadersHeader> <HeadersHint> Sent with every HTTP request to this server — cookies - included. If OAuth is configured later, the `Authorization` - header is owned by the OAuth flow and any value set here is - ignored. + included. A custom `Authorization` header takes precedence + over an OAuth access token, so remove it if you configure + OAuth for this server later. </HeadersHint> <KeyValueRows items={form.headers} diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 22410d5e..5faf82c2 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -611,9 +611,11 @@ export function ServerSettingsForm({ <Stack gap="md"> <Group justify="space-between"> <HintText> - Headers sent with every HTTP request to this server. If OAuth is - configured below, the `Authorization` header is owned by the - OAuth flow and any value set here is ignored. + Headers sent with every HTTP request to this server. A custom + `Authorization` header takes precedence over an OAuth access + token — the SDK transports apply these headers last — so remove + it once OAuth is configured, or the flow's token never gets + sent. </HintText> <AddButton onClick={onAddHeader}>+ Add Header</AddButton> </Group> diff --git a/clients/web/src/utils/keyValuePairs.ts b/clients/web/src/utils/keyValuePairs.ts new file mode 100644 index 00000000..2625e651 --- /dev/null +++ b/clients/web/src/utils/keyValuePairs.ts @@ -0,0 +1,15 @@ +/** + * One editable `{ key, value }` row — the in-memory shape the Inspector uses + * for custom headers, request metadata, and stdio environment variables + * (`InspectorServerSettings.headers` and friends). A pair *array* rather than + * a record specifically so a controlled form can hold a half-typed row with a + * blank key; the persist layer collapses it to a record and drops the blanks. + * + * Declared here, in `utils`, because it is a pure domain type shared by a + * component (`KeyValueRows`, which re-exports it) and non-UI logic + * (`serverSettingsPatch`). Imports point `components -> utils`, never back. + */ +export interface KeyValuePair { + key: string; + value: string; +} diff --git a/clients/web/src/utils/serverSettingsPatch.ts b/clients/web/src/utils/serverSettingsPatch.ts index 25fe7709..b7500c44 100644 --- a/clients/web/src/utils/serverSettingsPatch.ts +++ b/clients/web/src/utils/serverSettingsPatch.ts @@ -1,6 +1,5 @@ import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; -import type { ServerConfigModalMode } from "../components/groups/ServerConfigModal/ServerConfigModal"; -import type { KeyValuePair } from "../components/elements/KeyValueRows/KeyValueRows"; +import type { KeyValuePair } from "./keyValuePairs"; /** * Decide what `settings` node ServerConfigModal's submit should send for the @@ -32,7 +31,11 @@ import type { KeyValuePair } from "../components/elements/KeyValueRows/KeyValueR * add or clone. */ export function buildHeaderSettingsPatch( - mode: ServerConfigModalMode, + // Spelled as a literal union rather than imported from ServerConfigModal: + // `utils` must not depend on `components`. It stays honest because + // `ServerConfigModalMode` is checked against it at the call site — adding a + // fourth mode there is a compile error here. + mode: "add" | "edit" | "clone", existingSettings: InspectorServerSettings | undefined, headers: KeyValuePair[], emptySettings: InspectorServerSettings, From 1ad3edd96d4fab3ff1f37d8102db21556f5a5609 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:32:27 -0400 Subject: [PATCH 197/941] fix: refuse a template that cannot expand, instead of reading the raw one Ports the two behaviors PR #2033 (the parallel attempt at #1919, now closed as a duplicate) got right and this branch did not. 1. An expression declaring no variable is a malformed template, not one with a member to skip. RFC 6570 requires at least one varspec per expression and admits no empty member, so `{}`, `{,}`, `{a,}`, `{?}` and `{*}` (an explode modifier is not a name) now make the template invalid. Skipping them was the more dangerous reading: `x://{}` expanded to `x://` while the form rendered no inputs, so its "everything required is filled" check was vacuously true and it submitted a URI that is not the template the server published. 2. The read is withheld when expansion fails. `expandUriTemplate`'s raw-template fallback exists for the preview, which runs during render -- but the submit path used it too, so an invalid template was read with its braces intact and the server answered with a confusing "not found" for a defect that is not the user's. `tryExpandUriTemplate` returns the URI or the reason as a value the caller cannot mistake for one; the panel gates Read Resource on it and prints the reason. This also covers a value that cannot be encoded -- an unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent` throws `URIError` on it, and a text input can hold one via paste. `requiredGroups` now skips an expression naming no variable: an empty group can never be satisfied, so it would gate the form a second time on a condition nothing can meet, and the "any one of" hint built from it would name no fields. The accurate reason is the malformed template, which the expansion gate reports. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 7 +- README.md | 2 + .../ResourceTemplatePanel.test.tsx | 32 ++++++ .../ResourceTemplatePanel.tsx | 40 ++++++-- .../web/src/test/core/mcp/uriTemplate.test.ts | 63 ++++++++++++ clients/web/src/utils/uriTemplate.ts | 2 + core/mcp/uriTemplate.ts | 98 ++++++++++++++----- 7 files changed, 210 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4636e17e..aa442e66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,12 @@ v2/main/ │ │ # and double-encodes pct-triplets), and `{v}` │ │ # (encodeURIComponent leaves `!'()*` bare). │ │ # Requiredness is per EXPRESSION, not per variable — -│ │ # requiredGroups + hasRequiredValues — #1919; +│ │ # requiredGroups + hasRequiredValues. A template that +│ │ # cannot expand ({id:abc}, {}, {a,}) WITHHOLDS the read: +│ │ # tryExpandUriTemplate returns the reason as a value, and +│ │ # the panel disables Read Resource rather than sending the +│ │ # raw template — expandUriTemplate's raw-template fallback +│ │ # is for DISPLAY (the preview) only — #1919; │ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; diff --git a/README.md b/README.md index c7fa48e0..ec2af96c 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,8 @@ The SDK's `UriTemplate` is still used, but only to *validate* a template (constr The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. +A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. The panel disables Read Resource, prints the reason, and leaves the raw template in the preview. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. + Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). #### Advertised extensions diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index 4955b385..87b2ba15 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -243,6 +243,38 @@ describe("ResourceTemplatePanel", () => { screen.getByText("foobar://events?topic=news"), ).toBeInTheDocument(); }); + + // A template the server advertised can be malformed. There is no URI to + // send for one, so the read is withheld and the reason shown -- reading the + // raw template with its braces intact would draw a confusing "not found" + // from the server for a defect that is not the user's. + it.each([ + ["an out-of-grammar prefix modifier", "x://items/{id:abc}"], + ["an expression declaring no variable", "x://items/{}"], + ])("refuses to read a template with %s", (_label, uriTemplate) => { + const onReadResource = vi.fn(); + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Broken", uriTemplate }} + onReadResource={onReadResource} + />, + ); + expect( + screen.getByRole("button", { name: "Read Resource" }), + ).toBeDisabled(); + expect(screen.getByText(/Invalid RFC 6570 varspec/)).toBeInTheDocument(); + expect(onReadResource).not.toHaveBeenCalled(); + }); + + it("shows the malformed template unexpanded in the preview", () => { + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Broken", uriTemplate: "x://items/{}" }} + onReadResource={vi.fn()} + />, + ); + expect(screen.getByText("x://items/{}")).toBeInTheDocument(); + }); }); describe("completions", () => { diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index 9c104821..cb6a1589 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -15,11 +15,11 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { - expandUriTemplate, hasRequiredValues, previewUriTemplate, requiredGroups, templateVariables, + tryExpandUriTemplate, } from "../../../utils/uriTemplate"; export interface ResourceTemplatePanelProps { @@ -69,6 +69,13 @@ const DescriptionText = Text.withProps({ c: "dimmed", }); +// Why Read Resource is disabled when the template itself cannot be expanded. +// Without it the button is inert with nothing on screen explaining the refusal. +const ExpansionErrorText = Text.withProps({ + size: "sm", + c: "red", +}); + // Left-aligned so the action sits closest to the sidebar controls / the form // fields above; annotation badges trail it. const FooterRow = Group.withProps({ @@ -221,15 +228,29 @@ export function ResourceTemplatePanel({ void runCompletion(varName, value, buildContext(varName)); } - // Only the expressions whose absence would change the URI's shape gate the - // read; an unfilled `{?topic}` is a legitimate request for the unfiltered - // resource, and RFC 6570 drops the whole expression for it. The rule is - // per-expression rather than per-variable -- `{a,b}` with only `a` filled - // expands to `a`'s value -- so it lives in core beside the expander. - const canSubmit = hasRequiredValues(groups, variables); + // Two independent gates on the read. + // + // First, whether the values cover what expansion structurally needs. Only the + // expressions whose absence would change the URI's shape count; an unfilled + // `{?topic}` is a legitimate request for the unfiltered resource, and RFC 6570 + // drops the whole expression for it. The rule is per-expression rather than + // per-variable -- `{a,b}` with only `a` filled expands to `a`'s value -- so it + // lives in core beside the expander. + // + // Second, whether the template expands at all. A template the server + // advertised can be malformed (`{id:abc}`, `{a,}`) and a pasted value can be + // unencodable, and in neither case is there a URI to send -- so withhold the + // request and say why, rather than reading the raw template with its braces + // intact and letting the server answer with a confusing "not found". + const expansion = tryExpandUriTemplate(uriTemplate, variables); + const canSubmit = + expansion.error === undefined && hasRequiredValues(groups, variables); function handleSubmit() { - onReadResource(expandUriTemplate(uriTemplate, variables)); + /* v8 ignore next -- unreachable: the button is disabled whenever the + expansion failed, which is the only way `uri` is undefined. */ + if (expansion.uri === undefined) return; + onReadResource(expansion.uri); } const preview = previewUriTemplate(uriTemplate, variables); @@ -308,6 +329,9 @@ export function ResourceTemplatePanel({ ); })} </Stack> + {expansion.error !== undefined && ( + <ExpansionErrorText>{expansion.error}</ExpansionErrorText> + )} <FooterRow> <Button size="sm" disabled={!canSubmit} onClick={handleSubmit}> Read Resource diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index f9bfb9b1..26a4c9ee 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -6,6 +6,7 @@ import { parseUriTemplate, requiredGroups, templateVariables, + tryExpandUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; describe("parseUriTemplate", () => { @@ -506,3 +507,65 @@ describe("variable names that collide with Object.prototype", () => { expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a"); }); }); + +describe("an expression that declares no variable", () => { + // RFC 6570 requires at least one varspec per expression and admits no empty + // member, so each of these is a malformed template rather than one with a + // member to skip. Skipping is the dangerous reading: `x://{}` would expand to + // `x://` while the form rendered no inputs, so its "everything required is + // filled" check is vacuously true and it submits a URI that is not the + // template the server published. + it.each([ + ["an empty expression", "x://{}"], + ["only a separator", "x://{,}"], + ["a missing member", "x://{a,}"], + ["an operator and no name", "x://a{?}"], + // `*` is an explode modifier, not a name -- it is stripped before the + // emptiness test, so this must be rejected the same way. + ["only an explode modifier", "x://{*}"], + ])("strict rejects %s", (_label, template) => { + expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow( + /Invalid RFC 6570 varspec/, + ); + }); + + it("declares no required group for it, so only the expansion gate reports it", () => { + // An empty group could never be satisfied, and the form's "any one of" + // message built from it would name no fields -- the accurate reason is the + // malformed template, which tryExpandUriTemplate gives. + expect(requiredGroups("x://{}")).toEqual([]); + expect(tryExpandUriTemplate("x://{}", {}).error).toMatch( + /Invalid RFC 6570 varspec/, + ); + }); + + it("still reports the names a partly-empty expression does declare", () => { + expect(templateVariables("x://{a,}").map((v) => v.name)).toEqual(["a"]); + }); + + it("leaves the template unexpanded on the display path", () => { + expect(expandUriTemplate("x://{}", {})).toBe("x://{}"); + }); +}); + +describe("tryExpandUriTemplate", () => { + it("returns the URI and no error when the template expands", () => { + expect( + tryExpandUriTemplate("foobar://events/{topic}", { topic: "foo/bar" }), + ).toEqual({ uri: "foobar://events/foo%2Fbar" }); + }); + + it("returns the reason instead of a URI for a malformed template", () => { + const result = tryExpandUriTemplate("x://{id:abc}", { id: "7" }); + expect(result.uri).toBeUndefined(); + expect(result.error).toMatch(/Invalid RFC 6570 varspec/); + }); + + it("reports a value that cannot be encoded rather than throwing", () => { + // A lone surrogate has no UTF-8 encoding, so encodeURIComponent throws + // URIError on it -- and a text input can hold one via paste. + const result = tryExpandUriTemplate("x://{v}", { v: "\ud800" }); + expect(result.uri).toBeUndefined(); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index ea6c089b..54114c3e 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -22,8 +22,10 @@ export { parseUriTemplate, requiredGroups, templateVariables, + tryExpandUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; export type { + TemplateExpansion, TemplatePart, TemplateVariable, VarSpec, diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 7e5c1d87..805f33a1 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -92,9 +92,11 @@ interface TemplateExpression { /** Bare variable names, `*` and any `:length` modifier stripped. */ names: string[]; /** - * True when a varspec carried a modifier that is not valid RFC 6570. Strict - * expansion rejects the template; discovery stays lenient so the panel can - * still render something rather than going blank. + * True when a varspec is not one RFC 6570 admits — an out-of-grammar modifier + * (`{id:abc}`) or an empty member (`{}`, `{a,}`); see {@link parseVarSpec}. + * Strict expansion rejects such a template and the form withholds the read, + * while *discovery* stays lenient so the panel still renders whatever names + * the template does declare rather than going blank. */ invalid: boolean; } @@ -127,20 +129,28 @@ export interface TemplateVariable { const MAX_LENGTH_GRAMMAR = /^[1-9][0-9]{0,3}$/; /** - * Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix. + * Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix, + * or `"invalid"` for anything RFC 6570's `varspec` production does not admit. * - * Returns `null` for an empty varspec (a stray comma) and `"invalid"` for one - * whose modifier does not match the grammar. The two are distinguished because - * the first is ignorable and the second must fail the template: silently - * treating `{id:abc}` as `{id}` would send a URI that does not match what the - * server advertised, with nothing to alert anyone. + * Two shapes reach that verdict, and neither is ignorable: + * + * - **A modifier outside the grammar** (`{id:abc}`). Treating it as a plain + * `{id}` would send a URI that does not match what the server advertised, + * with nothing to alert anyone. + * - **An empty varspec** — `{}`, `{,}`, `{a,}`, `{?}`, `{*}`. RFC 6570 requires + * at least one varspec per expression and admits no empty member, so this is + * a malformed *template*, not an expression with a member to skip. Skipping + * it is the more dangerous reading: `x://{}` would expand to `x://` while the + * form rendered no inputs, so its "everything required is filled" check would + * be vacuously true and it would submit a URI that is not the template the + * server published. */ -function parseVarSpec(raw: string): VarSpec | null | "invalid" { +function parseVarSpec(raw: string): VarSpec | "invalid" { // The explode modifier is stripped rather than honored: it only changes how // a list or map value is joined, and every value reaching this module is a // single string. const spec = raw.replace("*", "").trim(); - if (spec.length === 0) return null; + if (spec.length === 0) return "invalid"; const colon = spec.indexOf(":"); if (colon === -1) return { name: spec }; @@ -183,7 +193,7 @@ export function parseUriTemplate(uriTemplate: string): TemplatePart[] { const operator = OPERATORS.find((op) => body.startsWith(op)) ?? ""; const parsed = body.slice(operator.length).split(",").map(parseVarSpec); const varspecs = parsed.filter( - (spec): spec is VarSpec => spec !== null && spec !== "invalid", + (spec): spec is VarSpec => spec !== "invalid", ); parts.push({ kind: "expression", @@ -243,6 +253,12 @@ export function requiredGroups(uriTemplate: string): string[][] { for (const part of parseUriTemplate(uriTemplate)) { if (part.kind !== "expression") continue; if (OMITTABLE_OPERATORS.has(part.operator)) continue; + // An expression naming no variable at all (`x://{}`) is a malformed + // template, which {@link tryExpandUriTemplate} refuses with that as the + // reason. Emitting an empty group here too would gate the form a second + // time on a condition nothing can satisfy, and the "any one of" message + // built from it would name no fields. + if (part.names.length === 0) continue; groups.push(part.names); } return groups; @@ -472,31 +488,63 @@ export function expandUriTemplateStrict( throw new Error( `Invalid RFC 6570 varspec in "${uriTemplate}": ${ bad.kind === "expression" ? bad.source : "" - } — a prefix modifier must be 1-9999 with no leading zero.`, + } — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`, ); } return expandParts(parts, defined); } +/** + * The outcome of an expansion: the URI, or the reason there isn't one. + * + * This is what a form gates its submit on. The lenient + * {@link expandUriTemplate} below is for *display* — it answers with the raw + * template so a panel can keep rendering — and a caller that mistook that + * fallback for a URI would issue a `resources/read` for the template itself, + * literal braces and all. Making the failure a value rather than a string the + * caller has to recognize is what keeps that from being possible. + * + * Failure is not only the malformed-template case: a value carrying an + * unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent` throws + * `URIError` on it, and a text input can hold one via paste. + */ +export type TemplateExpansion = + | { uri: string; error?: undefined } + | { uri?: undefined; error: string }; + +/** + * {@link expandUriTemplateStrict} with the throw turned into a value, for + * callers that must *decide* rather than degrade — chiefly the web panel, which + * disables Read Resource and shows the reason instead of sending a URI it knows + * is wrong. + */ +export function tryExpandUriTemplate( + uriTemplate: string, + values: Record<string, string>, +): TemplateExpansion { + try { + return { uri: expandUriTemplateStrict(uriTemplate, values) }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + /** * {@link expandUriTemplateStrict}, but falling back to the raw template string * instead of throwing. * - * This is the form's variant: the template comes from the connected server, not - * from the user, so an invalid one is not something the user can fix from the - * panel — and what they already see in the URI preview is the raw template. - * Letting it throw would take out the whole panel on render; returning it - * unchanged sends the server a URI it rejects with a legible error instead. - * Call sites that need the failure (`readResourceFromTemplate`, which wraps it - * with the template name) use the strict variant. + * This is the **display** variant, and only that: it runs during render (the + * URI preview), where a throw would take out the whole panel, and the raw + * template is the honest thing to show for a template nothing can expand. + * + * Do **not** submit what it returns. A caller deciding whether to issue a read + * uses {@link tryExpandUriTemplate}, whose failure is a value it cannot + * mistake for a URI; `readResourceFromTemplate` uses the strict variant and + * wraps the error with the template's name. */ export function expandUriTemplate( uriTemplate: string, values: Record<string, string>, ): string { - try { - return expandUriTemplateStrict(uriTemplate, values); - } catch { - return uriTemplate; - } + return tryExpandUriTemplate(uriTemplate, values).uri ?? uriTemplate; } From 1c24eda42921b37bcdefc829a11c0a8f694fc470 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:48:36 -0400 Subject: [PATCH 198/941] fix: validate the whole varspec, and derive the TUI's message from its gate Addresses Copilot's round-7 review on #2035. Two suppressed comments, both real; each reproduced against this branch before acting. 1. `parseVarSpec` was a "strip the first `*`, trim, split on `:`" pass, so it accepted shapes RFC 6570's varspec production does not admit -- measured on the previous commit: x://{*id} name "id" => "x://abcdef" explode is a TRAILING modifier x://{id*:3} name "id" => "x://abc" the two modifiers are exclusive x://{ id } name "id" => "x://abcdef" whitespace is not a varchar x://{a b} name "a b" => "x://1" ditto -- and it emits into the URI `{id*:3}` is the worst of them: it silently truncated the value through a combination the grammar forbids. Parsing is now one anchored regex -- a varname, then at most one trailing `*` or `:1-9999`. The name set is `varchar` widened by `-` and `~`, deliberately. A name is the one thing expansion emits into the URI WITHOUT encoding it (`;`, `?` and `&` write `name=value`), so the rule it has to satisfy is "needs no encoding" -- RFC 3986 unreserved plus pct-encoded, of which the RFC's own varchar is a subset. Strict varchar would refuse `{user-id}`, which real servers publish and which the SDK's matcher round-trips; refusing to read a resource that demonstrably works is a worse outcome for a debugging tool than a name one character outside the production. Everything now rejected is a shape we would otherwise have to guess at. 2. The TUI built its "Missing required template variable(s): ..." list with its own bare `values[name]` filter while gating on `hasRequiredValues`, which reads own properties only. For a variable legitimately named `constructor` the two disagreed: `({})["constructor"]` is `Object`, whose `.length` is 1, so the list came out empty and the error named no field at all while the submit stayed blocked. Both now come from one exported `unmetRequiredGroups`, with `hasRequiredValues` defined in terms of it. Also renames a test whose title still said multi-name query expressions were left "to the SDK, which handles them" -- expansion has been owned here since 935113d1; the behavior it asserts is naming, not delegation. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 2 +- .../tui/__tests__/ResourceTestModal.test.tsx | 17 +++ .../tui/src/components/ResourceTestModal.tsx | 19 ++-- .../web/src/test/core/mcp/uriTemplate.test.ts | 82 +++++++++++++- .../integration/mcp/rfc6570-templates.test.ts | 23 +++- core/mcp/uriTemplate.ts | 103 ++++++++++++++---- test-servers/src/test-server-fixtures.ts | 18 +++ 7 files changed, 228 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index ec2af96c..0f29e549 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ The SDK's `UriTemplate` is still used, but only to *validate* a template (constr The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. -A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. The panel disables Read Resource, prints the reason, and leaves the raw template in the preview. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. +A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx index 0067d211..26dfdc4d 100644 --- a/clients/tui/__tests__/ResourceTestModal.test.tsx +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -95,6 +95,23 @@ describe("ResourceTestModal", () => { unmount(); }); + it("blocks a required variable whose name collides with Object.prototype", async () => { + // `constructor` is a valid RFC 6570 varname, and the modal's own filter + // used to read `Object` (length 1) as a filled value. The *message* built + // from that filter came out naming no field; since frames render empty + // here (see the header note), that half is asserted on + // `unmetRequiredGroups` in the core suite. This asserts the gate. + const read = vi.fn(); + const { onClose, unmount } = await renderAndSubmit( + fakeClient(read), + makeTemplate({ uriTemplate: "x://{constructor}" }), + { constructor: "" }, + ); + expect(read).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + unmount(); + }); + it("submits once any one name in the group has a value", async () => { const read = vi.fn().mockResolvedValue({ result: { contents: [] }, diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index 37d662a2..0ac77a5d 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -6,8 +6,8 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/client"; import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; import { - hasRequiredValues, requiredGroups, + unmetRequiredGroups, } from "@inspector/core/mcp/uriTemplate.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; @@ -142,13 +142,16 @@ export function ResourceTestModal({ // and its members are left optional there. Without this check the TUI would // submit `{a,b}` completely blank -- dropping the expression and reading a // different resource -- while the web panel blocks the same request (#1919). - const groups = requiredGroups(template.uriTemplate); - if (!hasRequiredValues(groups, values)) { - const unmet = groups - .filter( - (names) => !names.some((name) => (values[name] ?? "").length > 0), - ) - .map((names) => names.join(" or ")); + // The message and the gate come from the SAME pass. Filtering the groups + // again here with a bare `values[name]` disagreed with the gate for a + // variable named `constructor` or `toString`: the inherited member read as + // filled, so the list came out empty and the error named no field at all. + const unmetGroups = unmetRequiredGroups( + requiredGroups(template.uriTemplate), + values, + ); + if (unmetGroups.length > 0) { + const unmet = unmetGroups.map((names) => names.join(" or ")); setResult({ input: values, output: null, diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 26a4c9ee..7c9d5241 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -7,6 +7,7 @@ import { requiredGroups, templateVariables, tryExpandUriTemplate, + unmetRequiredGroups, } from "@inspector/core/mcp/uriTemplate.js"; describe("parseUriTemplate", () => { @@ -205,7 +206,10 @@ describe("expandUriTemplate - multi-name expressions (SDK correction)", () => { expect(expandUriTemplate("x://a{/p,q}", {})).toBe("x://a"); }); - it("leaves multi-name query expressions to the SDK, which handles them", () => { + // Naming, not delegation: a multi-name QUERY expression pairs each name with + // its own value and joins with `&` (RFC 6570 §3.2.8), unlike the bare + // comma-joined list every non-named operator above produces. + it("pairs each name with its value in a multi-name query expression", () => { expect(expandUriTemplate("x://a{?p,q}", { p: "x/y", q: "z" })).toBe( "x://a?p=x%2Fy&q=z", ); @@ -548,6 +552,82 @@ describe("an expression that declares no variable", () => { }); }); +describe("varspec grammar", () => { + // Each of these expanded rather than being refused before the anchored + // grammar landed -- measured on the previous commit: `{*id}` and `{ id }` + // both yielded "x://abcdef", and `{id*:3}` silently truncated to "x://abc" + // through a modifier combination RFC 6570 does not allow. + it.each([ + ["explode in the leading position", "x://{*id}"], + ["explode combined with a prefix", "x://{id*:3}"], + ["whitespace around the name", "x://{ id }"], + ["whitespace inside the name", "x://{a b}"], + ["a name outside the character set", "x://{a/b}"], + ])("strict rejects %s", (_label, template) => { + expect(() => + expandUriTemplateStrict(template, { + id: "abcdef", + a: "1", + "a/b": "1", + "a b": "1", + }), + ).toThrow(/Invalid RFC 6570 varspec/); + }); + + it.each([ + // RFC 6570 varchar, plus the two RFC 3986 unreserved characters the set is + // deliberately widened by -- a name is emitted verbatim by `?`/`&`/`;`, so + // the rule is "needs no encoding", and `{user-id}` is common in the wild. + ["a plain name", "x://{id}", { id: "7" }, "x://7"], + ["a hyphen", "x://{user-id}", { "user-id": "7" }, "x://7"], + ["a tilde", "x://{a~b}", { "a~b": "7" }, "x://7"], + ["an underscore", "x://{a_b}", { a_b: "7" }, "x://7"], + ["a dot separator", "x://{a.b}", { "a.b": "7" }, "x://7"], + ["a pct-encoded varchar", "x://{a%2Fb}", { "a%2Fb": "7" }, "x://7"], + ["a trailing explode", "x://{id*}", { id: "7" }, "x://7"], + ["a trailing prefix", "x://{id:3}", { id: "abcdef" }, "x://abc"], + ])("accepts %s", (_label, template, values, expected) => { + expect(expandUriTemplate(template, values)).toBe(expected); + }); + + it.each(["x://{a.}", "x://{a..b}"])( + "rejects the misplaced dot in %s", + (template) => { + // varname = varchar *( ["."] varchar ) -- a dot separates, it cannot + // trail or double. + expect(() => expandUriTemplateStrict(template, {})).toThrow( + /Invalid RFC 6570 varspec/, + ); + }, + ); + + it("reads a leading dot as the label operator, not a malformed name", () => { + // `{.a}` is label expansion of `a` -- the one "misplaced dot" that is not + // one, since the operator is stripped before the varspec is parsed. + expect(expandUriTemplate("x://a{.ext}", { ext: "json" })).toBe( + "x://a.json", + ); + }); +}); + +describe("unmetRequiredGroups", () => { + it("names the groups that are missing", () => { + expect(unmetRequiredGroups([["a", "b"], ["c"]], { c: "1" })).toEqual([ + ["a", "b"], + ]); + }); + + it("agrees with hasRequiredValues on a prototype-collision name", () => { + // The TUI derived its "Missing required template variable(s)" list with its + // own bare `values[name]` filter: `({})["constructor"]` is `Object`, whose + // `.length` is 1, so the group read as satisfied and the message named no + // field while the gate still blocked the submit. + const groups = [["constructor"]]; + expect(hasRequiredValues(groups, {})).toBe(false); + expect(unmetRequiredGroups(groups, {})).toEqual([["constructor"]]); + }); +}); + describe("tryExpandUriTemplate", () => { it("returns the URI and no error when the template expands", () => { expect( diff --git a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts index 5765c369..c79872c8 100644 --- a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts +++ b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; -import { expandUriTemplate } from "@inspector/core/mcp/uriTemplate.js"; +import { + expandUriTemplate, + tryExpandUriTemplate, +} from "@inspector/core/mcp/uriTemplate.js"; import { createTestServerHttp, type TestServerHttp, @@ -83,15 +86,31 @@ describe("RFC 6570 resource templates over the wire (#1919)", () => { return connected; } - it("advertises both templates from the checked-in config", async () => { + it("advertises every template from the checked-in config", async () => { const connected = await connectToShowcase(); const { resourceTemplates } = await connected.listAllResourceTemplates(); expect(resourceTemplates.map((entry) => entry.uriTemplate).sort()).toEqual([ + // The malformed one is advertised precisely so a client has to decide + // what to do with it; the SDK's own constructor accepts it, so nothing + // upstream of the Inspector rejects it. See the refusal test below. + "foobar://events/{topic:abc}", "foobar://events/{topic}", "foobar://events{?topic}", ]); }); + it("refuses to expand the malformed template rather than guessing", async () => { + // `abc` is not RFC 6570's `max-length` production, so there is no URI to + // send. Guessing `{topic}` would read something the server never + // advertised -- and this server would answer it, which is exactly why the + // check has to happen client-side. + const result = tryExpandUriTemplate("foobar://events/{topic:abc}", { + topic: "news", + }); + expect(result.uri).toBeUndefined(); + expect(result.error).toMatch(/Invalid RFC 6570 varspec/); + }); + it("resolves the encoded URI the simple expression expands to", async () => { const connected = await connectToShowcase(); const uri = expandUriTemplate("foobar://events/{topic}", { diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 805f33a1..5150ac37 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -122,21 +122,62 @@ export interface TemplateVariable { } /** - * RFC 6570's `max-length` production: `%x31-39 0*3DIGIT` — 1 to 9999, no - * leading zero. `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are all invalid - * *templates*, not templates with an ignorable modifier. + * One character of a variable's name. + * + * RFC 6570's `varchar` is `ALPHA / DIGIT / "_" / pct-encoded`. This admits two + * more — `-` and `~` — and the widening is deliberate, on a rule the RFC's own + * set is a subset of: **a name is the one thing expansion emits into the URI + * without encoding it** (the `;`, `?` and `&` operators write `name=value`), so + * a name must consist of characters that need no encoding. That is RFC 3986's + * *unreserved* set — `ALPHA / DIGIT / -._~` — plus pct-encoded. + * + * Strict `varchar` would refuse `{user-id}`, which real servers publish and + * which the SDK's own matcher round-trips (`match()` compiles the hyphen + * literally). Refusing to read a resource that demonstrably works is a worse + * outcome for a debugging tool than tolerating a name one character outside the + * production — whereas everything this *does* reject is a shape we would + * otherwise have to **guess** at, which is the line the rest of this module + * draws too. */ -const MAX_LENGTH_GRAMMAR = /^[1-9][0-9]{0,3}$/; +const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`; + +/** + * RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either + * the explode `*` or a `:max-length`, never both. + * + * `varname = varchar *( ["."] varchar )`, so a `.` may separate name segments + * but cannot lead, trail, or double. `max-length` is `%x31-39 0*3DIGIT` — 1 to + * 9999, no leading zero — so `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are + * invalid *templates*, not templates with an ignorable modifier. + * + * Anchoring the whole varspec is what rejects the shapes a looser "strip the + * `*`, split on `:`" pass waves through. Measured against this branch before + * the change, each expanded rather than being refused: + * + * | Varspec | Was | Why it is invalid | + * | --------- | -------------------- | ----------------------------------- | + * | `{*id}` | name `id` | explode is a *trailing* modifier | + * | `{id*:3}` | name `id`, truncated | the two modifiers are exclusive | + * | `{ id }` | name `id` | whitespace is not a `varchar` | + * | `{a b}` | name `a b` | ditto — and it emits into the URI | + */ +const VARSPEC_GRAMMAR = new RegExp( + `^(${VARCHAR}(?:\\.?${VARCHAR})*)(?:\\*|:([1-9][0-9]{0,3}))?$`, +); /** * Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix, - * or `"invalid"` for anything RFC 6570's `varspec` production does not admit. + * or `"invalid"` for anything {@link VARSPEC_GRAMMAR} does not admit. * - * Two shapes reach that verdict, and neither is ignorable: + * Three classes reach that verdict, none of them ignorable: * - * - **A modifier outside the grammar** (`{id:abc}`). Treating it as a plain + * - **A modifier outside the grammar** (`{id:abc}`), or two at once (`{id*:3}`), + * or one in the wrong position (`{*id}`). Treating any of them as a plain * `{id}` would send a URI that does not match what the server advertised, - * with nothing to alert anyone. + * with nothing to alert anyone — and `{id*:3}` silently *truncated* the value. + * - **A name outside the character set** (`{a b}`, `{a/b}`). A named operator + * emits the name verbatim, so this is the one place an unencodable character + * reaches the URI as structure. * - **An empty varspec** — `{}`, `{,}`, `{a,}`, `{?}`, `{*}`. RFC 6570 requires * at least one varspec per expression and admits no empty member, so this is * a malformed *template*, not an expression with a member to skip. Skipping @@ -144,21 +185,15 @@ const MAX_LENGTH_GRAMMAR = /^[1-9][0-9]{0,3}$/; * form rendered no inputs, so its "everything required is filled" check would * be vacuously true and it would submit a URI that is not the template the * server published. + * + * The explode modifier parses but is not honored: it only changes how a list or + * map value is joined, and every value reaching this module is a single string. */ function parseVarSpec(raw: string): VarSpec | "invalid" { - // The explode modifier is stripped rather than honored: it only changes how - // a list or map value is joined, and every value reaching this module is a - // single string. - const spec = raw.replace("*", "").trim(); - if (spec.length === 0) return "invalid"; - - const colon = spec.indexOf(":"); - if (colon === -1) return { name: spec }; - - const name = spec.slice(0, colon); - const modifier = spec.slice(colon + 1); - if (name.length === 0 || !MAX_LENGTH_GRAMMAR.test(modifier)) return "invalid"; - return { name, maxLength: Number(modifier) }; + const match = VARSPEC_GRAMMAR.exec(raw); + if (match === null) return "invalid"; + const [, name, maxLength] = match; + return maxLength === undefined ? { name } : { name, maxLength: +maxLength }; } /** @@ -264,6 +299,28 @@ export function requiredGroups(uriTemplate: string): string[][] { return groups; } +/** + * The required expressions `values` does **not** satisfy — none of their names + * filled in — in template order. + * + * Exported so a caller that has to *name* the missing fields derives them from + * the same pass that decides whether anything is missing at all. The TUI built + * its "Missing required template variable(s): …" list with its own bare + * `values[name]` filter, which for a variable legitimately named `constructor` + * or `toString` found `Object.prototype`'s member and judged the group + * satisfied — so {@link hasRequiredValues} blocked the submit while the message + * listed nothing. + */ +export function unmetRequiredGroups( + groups: string[][], + values: Record<string, string>, +): string[][] { + return groups.filter( + (names) => + !names.some((name) => (readValue(values, name) ?? "").length > 0), + ); +} + /** * Whether `values` supplies everything expansion structurally needs: every * required expression has at least one of its names filled in. @@ -272,9 +329,7 @@ export function hasRequiredValues( groups: string[][], values: Record<string, string>, ): boolean { - return groups.every((names) => - names.some((name) => (readValue(values, name) ?? "").length > 0), - ); + return unmetRequiredGroups(groups, values).length === 0; } /** diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 9e58cfaa..5dd8cd29 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1539,6 +1539,24 @@ export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] { inputSchema: { topic: z.string().describe("Topic name") }, handler: echo("foobar://events{?topic}"), }, + { + // A template no client can honor: `abc` is not RFC 6570's `max-length` + // production (`%x31-39 0*3DIGIT`), so `{topic:abc}` is a malformed + // *template* rather than one with an ignorable modifier. The SDK's + // `UriTemplate` constructor accepts it, which is exactly why the + // Inspector checks the grammar itself and refuses the read -- guessing + // `{topic}` would send a URI this server never advertised. + // + // The handler is therefore unreachable through the Inspector by design. + // It is registered anyway so the template appears in + // `resources/templates/list`, which is what puts the refusal on screen. + name: "events_malformed", + uriTemplate: "foobar://events/{topic:abc}", + description: + "Malformed template - an out-of-grammar prefix modifier; the read is withheld", + inputSchema: { topic: z.string().describe("Topic name") }, + handler: echo("foobar://events/{topic:abc}"), + }, ]; } From 4dd8ed4498c4b53313a651fc3b7ec66624d41166 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 22:57:34 -0400 Subject: [PATCH 199/941] revert(web): drop custom headers from the manual add form (#1915) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server Settings already has a Custom Headers editor, so adding one to the manual add form created a second place to edit the same field. The reporter had also conditionally withdrawn #1915 — "editing that information from the server settings dialog is sufficient" once #1848 closed, which it has. Reverts the add-form editor, the shared KeyValueRows element, the optional `settings` argument on addServer / updateServer, and the App-level merge helper. What remains are two defects found while working in ServerSettingsForm, which this PR now closes instead (#2040): the Custom Headers hint misstating OAuth precedence, and the key/value rows' missing accessible names. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 61 ++---- .../KeyValueRows/KeyValueRows.stories.tsx | 60 ------ .../KeyValueRows/KeyValueRows.test.tsx | 126 ------------ .../elements/KeyValueRows/KeyValueRows.tsx | 114 ----------- .../ServerConfigModal.stories.tsx | 40 +--- .../ServerConfigModal.test.tsx | 180 ++---------------- .../ServerConfigModal/ServerConfigModal.tsx | 129 +++---------- .../ServerSettingsForm/ServerSettingsForm.tsx | 79 +++++++- .../src/test/core/react/useServers.test.tsx | 89 --------- clients/web/src/utils/keyValuePairs.ts | 15 -- .../web/src/utils/serverSettingsPatch.test.ts | 135 ------------- clients/web/src/utils/serverSettingsPatch.ts | 55 ------ core/react/useServers.ts | 44 +---- 13 files changed, 152 insertions(+), 975 deletions(-) delete mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx delete mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx delete mode 100644 clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx delete mode 100644 clients/web/src/utils/keyValuePairs.ts delete mode 100644 clients/web/src/utils/serverSettingsPatch.test.ts delete mode 100644 clients/web/src/utils/serverSettingsPatch.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index a32ecb90..72d8bd1f 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -138,8 +138,6 @@ import { ServerConfigModal, type ServerConfigModalMode, } from "./components/groups/ServerConfigModal/ServerConfigModal"; -import type { KeyValuePair } from "./components/elements/KeyValueRows/KeyValueRows"; -import { buildHeaderSettingsPatch } from "./utils/serverSettingsPatch"; import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal"; import { ClientSettingsModal } from "./components/groups/ClientSettingsModal/ClientSettingsModal"; import { @@ -3897,17 +3895,30 @@ function App() { // each id), deduped. The batch is reset to empty when an add/import modal // opens (see the menu handlers). const addServerHighlighted = useCallback( - async ( - id: string, - config: MCPServerConfig, - settings?: InspectorServerSettings, - ) => { - await addServer(id, config, settings); + async (id: string, config: MCPServerConfig) => { + await addServer(id, config); setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id])); }, [addServer], ); + // On rename of the active server, keep activeServerId pointed at the new id. + const onConfigSubmit = useCallback( + async (id: string, config: MCPServerConfig) => { + if (configModal?.mode === "edit" && configModal.targetId) { + const originalId = configModal.targetId; + await updateServer(originalId, id, config); + if (originalId === activeServerId && id !== originalId) { + setActiveServerId(id); + } + return; + } + // add or clone + await addServerHighlighted(id, config); + }, + [configModal, addServerHighlighted, updateServer, activeServerId], + ); + // Derive the existingIds list the modal uses for uniqueness validation. // In edit mode the target's own id must be excluded so saving without // renaming doesn't trip the "already exists" check. @@ -3924,39 +3935,6 @@ function App() { return servers.find((s) => s.id === configModal.targetId); }, [configModal, servers]); - // On rename of the active server, keep activeServerId pointed at the new id. - const onConfigSubmit = useCallback( - async (id: string, config: MCPServerConfig, headers: KeyValuePair[]) => { - // Headers live on the entry's `settings`, not on the transport config, - // so they're folded back in here (#1915). The edit-vs-clone rules live - // in the helper, which is unit-tested — this file is outside the - // coverage gate and that distinction is where credentials can leak. - const settings = buildHeaderSettingsPatch( - configModal?.mode ?? "add", - configModalTarget?.settings, - headers, - EMPTY_SETTINGS, - ); - if (configModal?.mode === "edit" && configModal.targetId) { - const originalId = configModal.targetId; - await updateServer(originalId, id, config, settings); - if (originalId === activeServerId && id !== originalId) { - setActiveServerId(id); - } - return; - } - // add or clone - await addServerHighlighted(id, config, settings); - }, - [ - configModal, - configModalTarget, - addServerHighlighted, - updateServer, - activeServerId, - ], - ); - const settingsModalTarget = useMemo(() => { if (!settingsModalTargetId) return undefined; return servers.find((s) => s.id === settingsModalTargetId); @@ -4572,7 +4550,6 @@ function App() { mode={configModal?.mode ?? "add"} initialId={configModalTarget?.id} initialConfig={configModalTarget?.config} - initialHeaders={configModalTarget?.settings?.headers} existingIds={existingIds} onClose={() => setConfigModal(null)} onSubmit={onConfigSubmit} diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx deleted file mode 100644 index 055a148b..00000000 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, fn, userEvent, within } from "storybook/test"; -import { KeyValueRows } from "./KeyValueRows"; - -const meta: Meta<typeof KeyValueRows> = { - title: "Elements/KeyValueRows", - component: KeyValueRows, - args: { - entityLabel: "header", - onChange: fn(), - onRemove: fn(), - }, -}; - -export default meta; -type Story = StoryObj<typeof KeyValueRows>; - -// Each row's controls carry a row-scoped accessible name, so a screen reader -// can tell one row's key box from another's. -export const Populated: Story = { - args: { - items: [ - { key: "Cookie", value: "branch=feature-x" }, - { key: "X-Env", value: "dev" }, - ], - }, - play: async ({ args, canvasElement }) => { - const canvas = within(canvasElement); - const value = await canvas.findByRole("textbox", { - name: "header value, Cookie, row 1", - }); - await expect(value).toHaveValue("branch=feature-x"); - - await userEvent.click( - canvas.getByRole("button", { name: "Remove header, X-Env, row 2" }), - ); - await expect(args.onRemove).toHaveBeenCalledWith(1); - }, -}; - -// A row whose key is still blank is named by position alone rather than -// announcing nothing. -export const BlankKeyRow: Story = { - args: { items: [{ key: "", value: "" }] }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect( - await canvas.findByRole("textbox", { name: "header name, row 1" }), - ).toBeInTheDocument(); - }, -}; - -// An empty list renders nothing — callers draw their own empty-state hint. -export const Empty: Story = { - args: { items: [] }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.queryAllByRole("textbox")).toHaveLength(0); - }, -}; diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx deleted file mode 100644 index 60e6916d..00000000 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.test.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { KeyValueRows } from "./KeyValueRows"; - -describe("KeyValueRows", () => { - function setup(items: { key: string; value: string }[], disabled?: boolean) { - const onChange = vi.fn(); - const onRemove = vi.fn(); - renderWithMantine( - <KeyValueRows - items={items} - entityLabel="header" - disabled={disabled} - onChange={onChange} - onRemove={onRemove} - />, - ); - return { onChange, onRemove }; - } - - it("renders nothing for an empty list", () => { - setup([]); - expect(screen.queryAllByRole("textbox")).toHaveLength(0); - }); - - it("names each control by entity and row so rows are distinguishable", () => { - setup([ - { key: "Cookie", value: "a=1" }, - { key: "", value: "" }, - ]); - expect( - screen.getByRole("textbox", { name: "header name, Cookie, row 1" }), - ).toHaveValue("Cookie"); - expect( - screen.getByRole("textbox", { name: "header value, Cookie, row 1" }), - ).toHaveValue("a=1"); - // A blank (or whitespace-only) key leaves the position alone as the name. - expect( - screen.getByRole("textbox", { name: "header name, row 2" }), - ).toBeInTheDocument(); - }); - - it("reports key and value edits with the row index", async () => { - const user = userEvent.setup({ delay: null }); - const { onChange } = setup([{ key: "X", value: "1" }]); - - await user.type( - screen.getByRole("textbox", { name: "header name, X, row 1" }), - "Y", - ); - expect(onChange).toHaveBeenLastCalledWith(0, "XY", "1"); - - await user.type( - screen.getByRole("textbox", { name: "header value, X, row 1" }), - "2", - ); - expect(onChange).toHaveBeenLastCalledWith(0, "X", "12"); - }); - - it("clears a key or a value through its own named Clear button", async () => { - const user = userEvent.setup({ delay: null }); - const { onChange } = setup([{ key: "X", value: "1" }]); - - // A bare "Clear" repeated per field would be indistinguishable across - // rows, so each clear button names the field it empties. - await user.click( - screen.getByRole("button", { name: "Clear header name, X, row 1" }), - ); - expect(onChange).toHaveBeenLastCalledWith(0, "", "1"); - - await user.click( - screen.getByRole("button", { name: "Clear header value, X, row 1" }), - ); - expect(onChange).toHaveBeenLastCalledWith(0, "X", ""); - }); - - it("omits the Clear button for an empty key or value", () => { - setup([{ key: "", value: "" }]); - expect(screen.queryAllByRole("button", { name: /^Clear/ })).toHaveLength(0); - }); - - it("locks every control when disabled", () => { - setup([{ key: "X", value: "1" }], true); - expect( - screen.getByRole("textbox", { name: "header name, X, row 1" }), - ).toBeDisabled(); - expect( - screen.getByRole("textbox", { name: "header value, X, row 1" }), - ).toBeDisabled(); - expect( - screen.getByRole("button", { name: "Clear header name, X, row 1" }), - ).toBeDisabled(); - expect( - screen.getByRole("button", { name: "Remove header, X, row 1" }), - ).toBeDisabled(); - }); - - it("distinguishes two rows that share a key", () => { - // Duplicate keys happen mid-edit, and a server can persist duplicate - // metadata, so the key alone cannot identify a row. - setup([ - { key: "Set-Cookie", value: "a=1" }, - { key: "Set-Cookie", value: "b=2" }, - ]); - expect( - screen.getByRole("textbox", { name: "header value, Set-Cookie, row 1" }), - ).toHaveValue("a=1"); - expect( - screen.getByRole("textbox", { name: "header value, Set-Cookie, row 2" }), - ).toHaveValue("b=2"); - }); - - it("removes the clicked row", async () => { - const user = userEvent.setup({ delay: null }); - const { onRemove } = setup([ - { key: "A", value: "1" }, - { key: "B", value: "2" }, - ]); - - await user.click( - screen.getByRole("button", { name: "Remove header, B, row 2" }), - ); - expect(onRemove).toHaveBeenCalledWith(1); - }); -}); diff --git a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx b/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx deleted file mode 100644 index 776ce5e9..00000000 --- a/clients/web/src/components/elements/KeyValueRows/KeyValueRows.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { ActionIcon, Group, TextInput } from "@mantine/core"; -import { ClearButton } from "../ClearButton/ClearButton"; -import type { KeyValuePair } from "../../../utils/keyValuePairs"; - -// Re-exported for call sites that already import this component, so they need -// not know the type lives a layer down. -export type { KeyValuePair }; - -export interface KeyValueRowsProps { - items: KeyValuePair[]; - /** - * Singular noun for one row ("header", "environment variable", …). Used only - * to build each control's `aria-label`: the section heading and the "Key" / - * "Value" placeholders are not programmatically associated with the inputs, - * so without it an assistive technology cannot tell one list's key box from - * another's, and the remove button announces only "X". The clear buttons are - * named the same way — a bare "Clear" repeated six times across three rows - * tells a screen-reader user nothing about which field it empties. - */ - entityLabel: string; - /** - * Lock every control in the list — used while a submit is in flight, so the - * rows can't drift out of sync with the payload the caller already captured. - */ - disabled?: boolean; - onChange: (index: number, key: string, value: string) => void; - onRemove: (index: number) => void; -} - -// Optional (non-required) clearable field — keeps the ClearButton clickable. -const ClearableTextInput = TextInput.withProps({ - rightSectionPointerEvents: "auto", -}); - -const RemoveIcon = ActionIcon.withProps({ - color: "red", - variant: "subtle", -}); - -/** - * Controlled editor for a list of `{ key, value }` pairs — the shape the - * Inspector persists for custom headers, request metadata, and stdio - * environment variables. Owns no state: every keystroke is reported through - * `onChange(index, key, value)` and the caller re-renders with the new list. - * - * Shared by ServerSettingsForm (headers / metadata / env) and ServerConfigModal - * (headers on the manual add form, #1915) so the two cannot drift. - */ -export function KeyValueRows({ - items, - entityLabel, - disabled, - onChange, - onRemove, -}: KeyValueRowsProps) { - return ( - <> - {items.map((item, index) => { - // The row number is always part of the label, not just a fallback for - // a blank key: two rows can carry the SAME key (mid-edit, or a - // duplicate a server legitimately persisted), and a key-only label - // would give both rows' controls identical accessible names — the very - // thing this labelling exists to prevent. - const key = item.key.trim(); - const rowLabel = key ? `${key}, row ${index + 1}` : `row ${index + 1}`; - return ( - <Group key={index} grow> - <ClearableTextInput - placeholder="Key" - aria-label={`${entityLabel} name, ${rowLabel}`} - value={item.key} - disabled={disabled} - onChange={(e) => - onChange(index, e.currentTarget.value, item.value) - } - rightSection={ - item.key ? ( - <ClearButton - aria-label={`Clear ${entityLabel} name, ${rowLabel}`} - disabled={disabled} - onClick={() => onChange(index, "", item.value)} - /> - ) : null - } - /> - <ClearableTextInput - placeholder="Value" - aria-label={`${entityLabel} value, ${rowLabel}`} - value={item.value} - disabled={disabled} - onChange={(e) => onChange(index, item.key, e.currentTarget.value)} - rightSection={ - item.value ? ( - <ClearButton - aria-label={`Clear ${entityLabel} value, ${rowLabel}`} - disabled={disabled} - onClick={() => onChange(index, item.key, "")} - /> - ) : null - } - /> - <RemoveIcon - aria-label={`Remove ${entityLabel}, ${rowLabel}`} - disabled={disabled} - onClick={() => onRemove(index)} - > - X - </RemoveIcon> - </Group> - ); - })} - </> - ); -} diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx index d44cd75e..c180a95b 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.stories.tsx @@ -19,8 +19,8 @@ function InteractiveRender(args: ServerConfigModalProps) { args.onClose(); updateArgs({ opened: false }); }} - onSubmit={async (id, config, headers) => { - await args.onSubmit(id, config, headers); + onSubmit={async (id, config) => { + await args.onSubmit(id, config); updateArgs({ opened: false }); }} /> @@ -134,39 +134,7 @@ export const EditSse: Story = { const body = within(canvasElement.ownerDocument.body); const dialog = within(await findDialog(body, "Edit server")); await expect(await dialog.findByLabelText(/^URL/)).toBeInTheDocument(); - // The headers editor starts collapsed to a bare "+ Add Header" control - // when the server has none stored. - await expect( - dialog.getByRole("button", { name: "+ Add Header" }), - ).toBeInTheDocument(); - await expect( - dialog.queryAllByRole("textbox", { name: /header name/ }), - ).toHaveLength(0); - }, -}; - -// Custom headers on the manual form (#1915) — the reason the field exists is a -// cookie routing requests to one developer's branch on a shared dev server. -export const EditSseWithHeaders: Story = { - args: { - mode: "edit", - initialId: "remote", - initialConfig: sseConfig, - initialHeaders: [{ key: "Cookie", value: "branch=feature-x" }], - existingIds: [], - }, - play: async ({ canvasElement }) => { - const body = within(canvasElement.ownerDocument.body); - const dialog = within(await findDialog(body, "Edit server")); - const value = (await dialog.findByRole("textbox", { - name: "header value, Cookie, row 1", - })) as HTMLInputElement; - await expect(value.value).toBe("branch=feature-x"); - - // Adding a row appends an empty pair, named by position until it is keyed. - await userEvent.click(dialog.getByRole("button", { name: "+ Add Header" })); - await expect( - await dialog.findByRole("textbox", { name: "header name, row 2" }), - ).toBeInTheDocument(); + // Headers are no longer entered here — they live in ServerSettingsForm. + await expect(dialog.queryByLabelText(/Headers/i)).toBeNull(); }, }; diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx index 0a3416c6..2f6f4e40 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.test.tsx @@ -66,11 +66,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Add$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "alpha", - { type: "stdio", command: "node", args: ["x.js", "--port=3000"] }, - [], - ); + expect(props.onSubmit).toHaveBeenCalledWith("alpha", { + type: "stdio", + command: "node", + args: ["x.js", "--port=3000"], + }); }); it("requires a command for stdio submission", async () => { @@ -117,7 +117,7 @@ describe("ServerConfigModal", () => { expect(screen.queryByLabelText(/^Command/)).not.toBeInTheDocument(); }); - it("submits an sse config with just the url and no headers", async () => { + it("submits an sse config with just the url (headers move to the settings form)", async () => { const user = userEvent.setup({ delay: null }); const props = base(); renderWithMantine( @@ -133,11 +133,10 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Save$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "remote", - { type: "sse", url: "https://x.test/sse" }, - [], - ); + expect(props.onSubmit).toHaveBeenCalledWith("remote", { + type: "sse", + url: "https://x.test/sse", + }); }); it("submits a streamable-http config", async () => { @@ -153,11 +152,10 @@ describe("ServerConfigModal", () => { ); await user.click(screen.getByRole("button", { name: /^Save$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "http-srv", - { type: "streamable-http", url: "https://x.test/mcp" }, - [], - ); + expect(props.onSubmit).toHaveBeenCalledWith("http-srv", { + type: "streamable-http", + url: "https://x.test/mcp", + }); }); it("loads the url from a streamable-http config", () => { @@ -277,11 +275,11 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /^Add$/ })); await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "alpha", - { type: "stdio", command: "node", cwd: "/tmp/cwd" }, - [], - ); + expect(props.onSubmit).toHaveBeenCalledWith("alpha", { + type: "stdio", + command: "node", + cwd: "/tmp/cwd", + }); }); it("clears the Server ID field via its Clear button", async () => { @@ -472,144 +470,4 @@ describe("ServerConfigModal", () => { await user.click(screen.getByRole("button", { name: /Cancel/ })); expect(props.onClose).toHaveBeenCalledOnce(); }); - - describe("custom headers (#1915)", () => { - it("hides the headers section for stdio", () => { - renderWithMantine(<ServerConfigModal {...base()} />); - expect(screen.queryByText("Custom headers")).not.toBeInTheDocument(); - }); - - // Every keystroke re-renders the whole modal, and this case types four - // fields, so it runs several times slower under the coverage project's v8 - // instrumentation than in a plain unit run — enough to trip the 5s default. - // The extra ceiling is headroom for the instrumented run, not a hang guard. - const TYPING_HEAVY_TIMEOUT_MS = 20000; - - it( - "submits headers added on the manual add form", - async () => { - const user = userEvent.setup({ delay: null }); - const props = base(); - // Seeded on the http transport rather than driven through the - // Transport select — that select has its own test above, and walking - // its combobox here only added to the cost described above. - renderWithMantine( - <ServerConfigModal - {...props} - initialConfig={{ type: "streamable-http", url: "" }} - />, - ); - - await user.type(screen.getByLabelText(/Server ID/i), "remote"); - await user.type(screen.getByLabelText(/^URL/), "https://x.test/mcp"); - - await user.click(screen.getByRole("button", { name: "+ Add Header" })); - await user.type( - screen.getByRole("textbox", { name: /header name, row 1/ }), - "Cookie", - ); - await user.type( - screen.getByRole("textbox", { name: /header value, Cookie, row 1/ }), - "branch=feature-x", - ); - await user.click(screen.getByRole("button", { name: /^Add$/ })); - - await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "remote", - { type: "streamable-http", url: "https://x.test/mcp" }, - [{ key: "Cookie", value: "branch=feature-x" }], - ); - }, - TYPING_HEAVY_TIMEOUT_MS, - ); - - it("pre-populates the rows from initialHeaders and submits an edit", async () => { - const user = userEvent.setup({ delay: null }); - const props = base(); - renderWithMantine( - <ServerConfigModal - {...props} - mode="edit" - initialId="remote" - initialConfig={{ type: "sse", url: "https://x.test/sse" }} - initialHeaders={[{ key: "X-Env", value: "dev" }]} - />, - ); - - const valueInput = screen.getByRole("textbox", { - name: /header value, X-Env, row 1/, - }); - expect(valueInput).toHaveValue("dev"); - await user.type(valueInput, "-2"); - await user.click(screen.getByRole("button", { name: /^Save$/ })); - - await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "remote", - { type: "sse", url: "https://x.test/sse" }, - [{ key: "X-Env", value: "dev-2" }], - ); - }); - - it("drops blank-key rows and removes a row on X", async () => { - const user = userEvent.setup({ delay: null }); - const props = base(); - renderWithMantine( - <ServerConfigModal - {...props} - mode="edit" - initialId="remote" - initialConfig={{ type: "sse", url: "https://x.test/sse" }} - initialHeaders={[ - { key: "Keep", value: "1" }, - { key: "Drop", value: "2" }, - ]} - />, - ); - - // An empty row is the form's "still typing" placeholder — it must not - // reach the caller. - await user.click(screen.getByRole("button", { name: "+ Add Header" })); - // Editing one row of several must leave its siblings untouched. - await user.type( - screen.getByRole("textbox", { name: "header value, Keep, row 1" }), - "9", - ); - await user.click( - screen.getByRole("button", { name: "Remove header, Drop, row 2" }), - ); - await user.click(screen.getByRole("button", { name: /^Save$/ })); - - await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "remote", - { type: "sse", url: "https://x.test/sse" }, - [{ key: "Keep", value: "19" }], - ); - }); - - it("submits no headers for stdio even when initialHeaders is set", async () => { - const user = userEvent.setup({ delay: null }); - const props = base(); - renderWithMantine( - <ServerConfigModal - {...props} - mode="edit" - initialId="local" - initialConfig={{ type: "stdio", command: "node" }} - initialHeaders={[{ key: "X-Env", value: "dev" }]} - />, - ); - - await user.click(screen.getByRole("button", { name: /^Save$/ })); - - await waitFor(() => expect(props.onSubmit).toHaveBeenCalledOnce()); - expect(props.onSubmit).toHaveBeenCalledWith( - "local", - { type: "stdio", command: "node" }, - [], - ); - }); - }); }); diff --git a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx index 105bb121..e1e823a6 100644 --- a/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx +++ b/clients/web/src/components/groups/ServerConfigModal/ServerConfigModal.tsx @@ -10,10 +10,6 @@ import { Textarea, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { - KeyValueRows, - type KeyValuePair, -} from "../../elements/KeyValueRows/KeyValueRows"; import { useValueChange } from "../../../hooks/useValueChange"; import type { MCPServerConfig, @@ -32,27 +28,11 @@ export interface ServerConfigModalProps { initialId?: string; /** When editing or cloning, the existing config to pre-populate. */ initialConfig?: MCPServerConfig; - /** - * Custom HTTP headers already stored for the target server, pre-populated - * when editing or cloning. Headers are not part of `MCPServerConfig` — they - * live on the entry's `settings`, so they arrive (and leave, via `onSubmit`) - * as their own value rather than folded into the config. (#1915) - */ - initialHeaders?: KeyValuePair[]; /** Ids already in use — drives the uniqueness check (caller excludes the * target id from this list when in 'edit' mode). */ existingIds: string[]; onClose: () => void; - /** - * `headers` carries the edited custom-header rows (blank-key rows already - * dropped). It is always `[]` for stdio, which has no HTTP request to attach - * headers to. - */ - onSubmit: ( - id: string, - config: MCPServerConfig, - headers: KeyValuePair[], - ) => Promise<void> | void; + onSubmit: (id: string, config: MCPServerConfig) => Promise<void> | void; } type TransportChoice = "stdio" | "sse" | "streamable-http"; @@ -67,8 +47,6 @@ interface FormState { cwd: string; // sse / streamable-http url: string; - /** Custom HTTP headers — sse / streamable-http only. (#1915) */ - headers: KeyValuePair[]; } // The `string`-valued FormState keys, which all share the same text-input @@ -106,10 +84,6 @@ const EnvTextarea = Textarea.withProps({ minRows: 2, rightSectionPointerEvents: "auto", }); -const AddHeaderButton = Button.withProps({ size: "xs", variant: "light" }); -const HeadersHeader = Group.withProps({ justify: "space-between", gap: "sm" }); -const HeadersLabel = Text.withProps({ size: "sm", fw: 500 }); -const HeadersHint = Text.withProps({ size: "xs", c: "dimmed" }); const MODE_TITLES: Record<ServerConfigModalMode, string> = { add: "Add server", @@ -120,14 +94,9 @@ const MODE_TITLES: Record<ServerConfigModalMode, string> = { function configToFormState( initialId: string | undefined, initialConfig: MCPServerConfig | undefined, - initialHeaders: KeyValuePair[] | undefined, mode: ServerConfigModalMode, ): FormState { const id = mode === "edit" ? (initialId ?? "") : ""; - // Copy the rows rather than aliasing the caller's array — the form mutates - // this list as its own state, and a clone keeps a cancelled edit from - // reaching the entry the caller passed in. - const headers = (initialHeaders ?? []).map((h) => ({ ...h })); const transport: TransportChoice = initialConfig?.type === undefined ? "stdio" : initialConfig.type; if (!initialConfig) { @@ -139,7 +108,6 @@ function configToFormState( envText: "", cwd: "", url: "", - headers, }; } if (transport === "stdio") { @@ -154,9 +122,9 @@ function configToFormState( .join("\n"), cwd: c.cwd ?? "", url: "", - headers, }; } + // sse / streamable-http — custom headers live in ServerSettingsForm now. const url = initialConfig.type === "sse" || initialConfig.type === "streamable-http" ? initialConfig.url @@ -169,7 +137,6 @@ function configToFormState( envText: "", cwd: "", url: url ?? "", - headers, }; } @@ -218,14 +185,13 @@ export function ServerConfigModal({ mode, initialId, initialConfig, - initialHeaders, existingIds, onClose, onSubmit, }: ServerConfigModalProps) { const initial = useMemo( - () => configToFormState(initialId, initialConfig, initialHeaders, mode), - [initialId, initialConfig, initialHeaders, mode], + () => configToFormState(initialId, initialConfig, mode), + [initialId, initialConfig, mode], ); const [form, setForm] = useState<FormState>(initial); const [submitError, setSubmitError] = useState<string | undefined>(undefined); @@ -244,22 +210,6 @@ export function ServerConfigModal({ const clearTextField = (field: TextField) => () => setForm((f) => ({ ...f, [field]: "" })); - // Header rows are a pair array rather than a plain string, so they get their - // own handlers instead of riding `setTextField` (which `TextField` excludes - // by type). - const addHeader = () => - setForm((f) => ({ ...f, headers: [...f.headers, { key: "", value: "" }] })); - const removeHeader = (index: number) => - setForm((f) => ({ - ...f, - headers: f.headers.filter((_, i) => i !== index), - })); - const changeHeader = (index: number, key: string, value: string) => - setForm((f) => ({ - ...f, - headers: f.headers.map((h, i) => (i === index ? { key, value } : h)), - })); - // Reset the form whenever the modal opens, or whenever `initial` changes // while it is open. Keying on `opened ? initial : undefined` collapses both // triggers into one value: it flips to `initial` on open, tracks `initial` @@ -313,9 +263,9 @@ export function ServerConfigModal({ return { ok: false, error: "URL is required for sse / streamable-http." }; } const base = { url: form.url.trim() }; - // Custom headers are persisted under `settings.headers` on the entry, not - // on the config — they leave through `onSubmit`'s third argument, so the - // SSE / streamable-http config here carries only transport fields. + // Custom headers live in ServerSettingsForm now (persisted under + // settings.headers on the entry); the SSE / streamable-http config here + // only carries the canonical transport fields. const config: MCPServerConfig = form.transport === "sse" ? { type: "sse", ...base } @@ -338,19 +288,9 @@ export function ServerConfigModal({ setSubmitError(built.error); return; } - // Blank-key rows are the form's placeholder for "row being typed" and mean - // nothing on the wire — the persist layer drops them anyway, so drop them - // here too and keep the caller's payload honest. stdio has no HTTP request - // to carry headers, so it always submits none. - const headers = - form.transport === "stdio" - ? [] - : form.headers - .map((h) => ({ key: h.key.trim(), value: h.value })) - .filter((h) => h.key.length > 0); setSubmitting(true); try { - await onSubmit(trimmedId, built.config, headers); + await onSubmit(trimmedId, built.config); onClose(); } catch (err) { setSubmitError(err instanceof Error ? err.message : String(err)); @@ -472,44 +412,21 @@ export function ServerConfigModal({ /> </> ) : ( - <> - <RequiredTextInput - label="URL" - placeholder="https://example.com/mcp" - value={form.url} - onChange={setTextField("url")} - disabled={submitting} - rightSection={ - form.url ? ( - <ClearButton - disabled={submitting} - onClick={clearTextField("url")} - /> - ) : null - } - /> - <FieldGrid> - <HeadersHeader> - <HeadersLabel>Custom headers</HeadersLabel> - <AddHeaderButton onClick={addHeader} disabled={submitting}> - + Add Header - </AddHeaderButton> - </HeadersHeader> - <HeadersHint> - Sent with every HTTP request to this server — cookies - included. A custom `Authorization` header takes precedence - over an OAuth access token, so remove it if you configure - OAuth for this server later. - </HeadersHint> - <KeyValueRows - items={form.headers} - entityLabel="header" - disabled={submitting} - onChange={changeHeader} - onRemove={removeHeader} - /> - </FieldGrid> - </> + <RequiredTextInput + label="URL" + placeholder="https://example.com/mcp" + value={form.url} + onChange={setTextField("url")} + disabled={submitting} + rightSection={ + form.url ? ( + <ClearButton + disabled={submitting} + onClick={clearTextField("url")} + /> + ) : null + } + /> )} </FieldGrid> diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index 5faf82c2..48e67c05 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -13,7 +13,6 @@ import { TextInput, } from "@mantine/core"; import { ClearButton } from "../../elements/ClearButton/ClearButton"; -import { KeyValueRows } from "../../elements/KeyValueRows/KeyValueRows"; import type { ProtocolEra } from "@modelcontextprotocol/client"; import type { InspectorServerSettings, @@ -224,6 +223,84 @@ const ClearStoredOAuthHint = Text.withProps({ miw: "12rem", }); +function KeyValueRows({ + items, + entityLabel, + onChange, + onRemove, +}: { + items: { key: string; value: string }[]; + /** + * Singular noun for one row ("header", "environment variable", …). Used only + * to build each control's `aria-label`: the section heading and the "Key" / + * "Value" placeholders are not programmatically associated with the inputs, + * so without it an assistive technology cannot tell one section's key box + * from another's, and every remove button announces only "X". The row number + * rides along because two rows can carry the same key — mid-edit, or a + * duplicate a server persisted — and a key-only name would leave both rows' + * controls indistinguishable, which is the thing this exists to prevent. + */ + entityLabel: string; + onChange: (index: number, key: string, value: string) => void; + onRemove: (index: number) => void; +}) { + /* v8 ignore next 3 -- unreachable: every caller guards with `length === 0` + and renders an EmptyHint instead, so KeyValueRows is only mounted with + a non-empty list. */ + if (items.length === 0) { + return null; + } + + return ( + <> + {items.map((item, index) => { + const key = item.key.trim(); + const rowLabel = key ? `${key}, row ${index + 1}` : `row ${index + 1}`; + return ( + <Group key={index} grow> + <ClearableTextInput + placeholder="Key" + aria-label={`${entityLabel} name, ${rowLabel}`} + value={item.key} + onChange={(e) => + onChange(index, e.currentTarget.value, item.value) + } + rightSection={ + item.key ? ( + <ClearButton + aria-label={`Clear ${entityLabel} name, ${rowLabel}`} + onClick={() => onChange(index, "", item.value)} + /> + ) : null + } + /> + <ClearableTextInput + placeholder="Value" + aria-label={`${entityLabel} value, ${rowLabel}`} + value={item.value} + onChange={(e) => onChange(index, item.key, e.currentTarget.value)} + rightSection={ + item.value ? ( + <ClearButton + aria-label={`Clear ${entityLabel} value, ${rowLabel}`} + onClick={() => onChange(index, item.key, "")} + /> + ) : null + } + /> + <RemoveIcon + aria-label={`Remove ${entityLabel}, ${rowLabel}`} + onClick={() => onRemove(index)} + > + X + </RemoveIcon> + </Group> + ); + })} + </> + ); +} + // Reserved-key rejection is inline per row (#2018): the reason is passed as the // key input's `error` **string**, so Mantine renders it in the input's own error // slot and wires the `aria-describedby` association — a bare boolean would mark diff --git a/clients/web/src/test/core/react/useServers.test.tsx b/clients/web/src/test/core/react/useServers.test.tsx index d66beb4c..bb95402d 100644 --- a/clients/web/src/test/core/react/useServers.test.tsx +++ b/clients/web/src/test/core/react/useServers.test.tsx @@ -123,95 +123,6 @@ describe("useServers", () => { }); }); - it("addServer persists a settings node when one is supplied (#1915)", async () => { - const { result } = renderHook(() => - useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), - ); - await waitFor(() => expect(result.current.loading).toBe(false)); - - await act(async () => { - // The manual add form carries custom headers here — they live on - // `settings`, not on the transport config. - await result.current.addServer( - "remote", - { type: "streamable-http", url: "https://x.test/mcp" }, - { - headers: [{ key: "Cookie", value: "branch=feature-x" }], - env: [], - metadata: [], - connectionTimeout: 0, - requestTimeout: 0, - taskTtl: 60000, - maxFetchRequests: 1000, - roots: [], - }, - ); - }); - - await waitFor(() => { - const added = result.current.servers.find((srv) => srv.id === "remote"); - expect(added?.settings?.headers).toEqual([ - { key: "Cookie", value: "branch=feature-x" }, - ]); - }); - // On disk headers are the flat `Record<string, string>` form, a direct key - // on the entry (post-#1358) rather than a nested settings node. - expect(readConfig(h.configPath).mcpServers.remote?.headers).toEqual({ - Cookie: "branch=feature-x", - }); - }); - - it("updateServer replaces the settings node when one is supplied (#1915)", async () => { - writeFileSync( - h.configPath, - JSON.stringify({ - mcpServers: { - alpha: { - type: "streamable-http", - url: "https://x.test/mcp", - headers: { "X-Old": "1" }, - metadata: [{ key: "trace", value: "abc" }], - }, - }, - }), - ); - - const { result } = renderHook(() => - useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), - ); - await waitFor(() => expect(result.current.loading).toBe(false)); - - await act(async () => { - // Editing headers in ServerConfigModal sends the whole settings node, - // so the caller is responsible for carrying the untouched fields - // (here: metadata) forward. - await result.current.updateServer( - "alpha", - "alpha", - { type: "streamable-http", url: "https://x.test/mcp" }, - { - headers: [{ key: "X-New", value: "2" }], - env: [], - metadata: [{ key: "trace", value: "abc" }], - connectionTimeout: 0, - requestTimeout: 0, - taskTtl: 60000, - maxFetchRequests: 1000, - roots: [], - }, - ); - }); - - await waitFor(() => { - expect(result.current.servers[0]?.settings?.headers).toEqual([ - { key: "X-New", value: "2" }, - ]); - }); - expect(result.current.servers[0]?.settings?.metadata).toEqual([ - { key: "trace", value: "abc" }, - ]); - }); - it("importSource returns a result for a known source type", async () => { const { result } = renderHook(() => useServers({ baseUrl: "http://test.local", fetchFn: h.fetchFn }), diff --git a/clients/web/src/utils/keyValuePairs.ts b/clients/web/src/utils/keyValuePairs.ts deleted file mode 100644 index 2625e651..00000000 --- a/clients/web/src/utils/keyValuePairs.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * One editable `{ key, value }` row — the in-memory shape the Inspector uses - * for custom headers, request metadata, and stdio environment variables - * (`InspectorServerSettings.headers` and friends). A pair *array* rather than - * a record specifically so a controlled form can hold a half-typed row with a - * blank key; the persist layer collapses it to a record and drops the blanks. - * - * Declared here, in `utils`, because it is a pure domain type shared by a - * component (`KeyValueRows`, which re-exports it) and non-UI logic - * (`serverSettingsPatch`). Imports point `components -> utils`, never back. - */ -export interface KeyValuePair { - key: string; - value: string; -} diff --git a/clients/web/src/utils/serverSettingsPatch.test.ts b/clients/web/src/utils/serverSettingsPatch.test.ts deleted file mode 100644 index 06c892cf..00000000 --- a/clients/web/src/utils/serverSettingsPatch.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; -import { buildHeaderSettingsPatch } from "./serverSettingsPatch"; - -const EMPTY: InspectorServerSettings = { - headers: [], - env: [], - metadata: [], - connectionTimeout: 0, - requestTimeout: 0, - taskTtl: 60000, - autoRefreshOnListChanged: false, - paginatedLists: false, - maxFetchRequests: 1000, - roots: [], -}; - -// A server carrying settings the modal never shows — the fields an edit must -// preserve and a clone must not copy. -const POPULATED: InspectorServerSettings = { - ...EMPTY, - headers: [{ key: "X-Old", value: "1" }], - metadata: [{ key: "trace", value: "abc" }], - connectionTimeout: 5000, - oauthClientId: "cid", - oauthClientSecret: "shhh", - roots: [{ uri: "file:///project", name: "Project" }], -}; - -const NEW_HEADERS = [{ key: "Cookie", value: "branch=x" }]; - -describe("buildHeaderSettingsPatch", () => { - it("sends nothing when there are no headers on either side", () => { - expect( - buildHeaderSettingsPatch("add", undefined, [], EMPTY), - ).toBeUndefined(); - expect(buildHeaderSettingsPatch("edit", EMPTY, [], EMPTY)).toBeUndefined(); - }); - - it("carries the target's other settings forward on an edit", () => { - const patch = buildHeaderSettingsPatch( - "edit", - POPULATED, - NEW_HEADERS, - EMPTY, - ); - expect(patch).toEqual({ ...POPULATED, headers: NEW_HEADERS }); - // The node is replaced wholesale, so the fields the modal doesn't show - // have to travel with it. - expect(patch?.metadata).toEqual([{ key: "trace", value: "abc" }]); - expect(patch?.oauthClientSecret).toBe("shhh"); - expect(patch?.connectionTimeout).toBe(5000); - }); - - it("sends nothing when an edit leaves the headers untouched", () => { - // The modal holds a snapshot taken when it opened, so writing it back on - // an id/URL-only save would clobber a metadata or OAuth change made in the - // settings form since. Omitting the key is what preserves it. - expect( - buildHeaderSettingsPatch( - "edit", - POPULATED, - // A fresh array with equal contents — the modal always rebuilds it. - [{ key: "X-Old", value: "1" }], - EMPTY, - ), - ).toBeUndefined(); - }); - - it("sends when an edit only reorders the headers", () => { - const two: InspectorServerSettings = { - ...POPULATED, - headers: [ - { key: "A", value: "1" }, - { key: "B", value: "2" }, - ], - }; - expect( - buildHeaderSettingsPatch( - "edit", - two, - [ - { key: "B", value: "2" }, - { key: "A", value: "1" }, - ], - EMPTY, - ), - ).toEqual({ - ...two, - headers: [ - { key: "B", value: "2" }, - { key: "A", value: "1" }, - ], - }); - }); - - it("still sends on an edit that clears the last header", () => { - expect(buildHeaderSettingsPatch("edit", POPULATED, [], EMPTY)).toEqual({ - ...POPULATED, - headers: [], - }); - }); - - it("does not copy the source server's settings on a clone", () => { - // The regression this guards: `configModalTarget` in clone mode is the - // SOURCE entry, so spreading it put that server's OAuth client secret on a - // brand-new one. - const patch = buildHeaderSettingsPatch( - "clone", - POPULATED, - NEW_HEADERS, - EMPTY, - ); - expect(patch).toEqual({ ...EMPTY, headers: NEW_HEADERS }); - expect(patch?.oauthClientSecret).toBeUndefined(); - expect(patch?.oauthClientId).toBeUndefined(); - expect(patch?.metadata).toEqual([]); - expect(patch?.roots).toEqual([]); - expect(patch?.connectionTimeout).toBe(0); - }); - - it("builds from the empty shape on an add", () => { - expect( - buildHeaderSettingsPatch("add", undefined, NEW_HEADERS, EMPTY), - ).toEqual({ ...EMPTY, headers: NEW_HEADERS }); - }); - - it("ignores a stale target on a clone with no headers", () => { - // A clone of a server that HAS headers, submitted with none: nothing about - // the new entry needs a settings node. - expect( - buildHeaderSettingsPatch("clone", POPULATED, [], EMPTY), - ).toBeUndefined(); - }); -}); diff --git a/clients/web/src/utils/serverSettingsPatch.ts b/clients/web/src/utils/serverSettingsPatch.ts deleted file mode 100644 index b7500c44..00000000 --- a/clients/web/src/utils/serverSettingsPatch.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; -import type { KeyValuePair } from "./keyValuePairs"; - -/** - * Decide what `settings` node ServerConfigModal's submit should send for the - * custom headers it just collected (#1915). - * - * Two rules, both load-bearing: - * - * 1. **Only an edit carries the target's other settings forward.** The - * backend replaces the whole node when one is sent, so an edit must - * re-send the fields the modal doesn't expose (metadata, timeouts, OAuth - * credentials, roots) or they'd be dropped. An **add or clone must not**: - * the modal's target in clone mode is the *source* server, so spreading it - * would copy that server's OAuth client secret and behavior flags onto a - * new entry the user only gave a URL and some headers. - * 2. **`undefined` means "don't send the key at all", and that is the - * default.** Omitting `settings` makes the backend preserve the node it - * already has, which matters beyond convenience: what this modal holds is - * a snapshot taken when it opened, so writing it back on a save that did - * not touch a header would overwrite a metadata or OAuth change made in - * the settings form in the meantime. So the patch is sent only when the - * submitted headers actually differ from the stored ones — including the - * case where the last one was cleared, which does need the node rewritten. - * - * Lives here rather than inline in App.tsx so this seam is unit-testable: - * App.tsx is outside the coverage gate, and the edit-vs-clone distinction is - * exactly where credentials leaked before. - * - * @param emptySettings the app's blank settings shape, used as the base for an - * add or clone. - */ -export function buildHeaderSettingsPatch( - // Spelled as a literal union rather than imported from ServerConfigModal: - // `utils` must not depend on `components`. It stays honest because - // `ServerConfigModalMode` is checked against it at the call site — adding a - // fourth mode there is a compile error here. - mode: "add" | "edit" | "clone", - existingSettings: InspectorServerSettings | undefined, - headers: KeyValuePair[], - emptySettings: InspectorServerSettings, -): InspectorServerSettings | undefined { - const existing = mode === "edit" ? existingSettings : undefined; - if (sameHeaders(existing?.headers ?? [], headers)) return undefined; - return { ...(existing ?? emptySettings), headers }; -} - -/** - * Order-sensitive pair-list equality. Order matters because it is what the - * form round-trips and what the user sees, so a reorder is a real edit. - */ -function sameHeaders(a: KeyValuePair[], b: KeyValuePair[]): boolean { - if (a.length !== b.length) return false; - return a.every((h, i) => h.key === b[i]?.key && h.value === b[i]?.value); -} diff --git a/core/react/useServers.ts b/core/react/useServers.ts index f206a0d5..3a2a5002 100644 --- a/core/react/useServers.ts +++ b/core/react/useServers.ts @@ -29,28 +29,11 @@ export interface UseServersResult { loading: boolean; error: string | undefined; refresh: () => Promise<void>; - /** - * `settings` is optional: omitted, no settings node is persisted for the new - * entry. Passed, it is written alongside the config in the same POST — the - * add form needs this to carry custom headers, which live on `settings` - * rather than on the transport config (#1915). - */ - addServer: ( - id: string, - config: MCPServerConfig, - settings?: InspectorServerSettings, - ) => Promise<void>; - /** - * `settings` is optional and asymmetric with `addServer`'s: **omitting** it - * tells the backend to preserve the entry's existing settings node, so a - * config-only save cannot silently wipe persisted headers / metadata / OAuth - * credentials. Pass a full settings object to replace it. - */ + addServer: (id: string, config: MCPServerConfig) => Promise<void>; updateServer: ( originalId: string, newId: string, config: MCPServerConfig, - settings?: InspectorServerSettings, ) => Promise<void>; /** * Patch only the `settings` node on an existing server entry, leaving the @@ -219,17 +202,11 @@ export function useServers(opts: UseServersOptions): UseServersResult { }, [base, authToken, doFetch, refreshInternal]); const addServer = useCallback( - async ( - id: string, - config: MCPServerConfig, - settings?: InspectorServerSettings, - ): Promise<void> => { + async (id: string, config: MCPServerConfig): Promise<void> => { const res = await doFetch(`${base}/api/servers`, { method: "POST", headers: buildHeaders(authToken, true), - // `settings` is omitted from the body when undefined (JSON.stringify - // drops undefined values), which the route reads as "no settings node". - body: JSON.stringify({ id, config, settings }), + body: JSON.stringify({ id, config }), }); if (!res.ok) { throw new Error(await readErrorMessage(res)); @@ -258,21 +235,18 @@ export function useServers(opts: UseServersOptions): UseServersResult { originalId: string, newId: string, config: MCPServerConfig, - settings?: InspectorServerSettings, ): Promise<void> => { - // With `settings` undefined, `JSON.stringify` drops the key entirely and - // the backend route treats that omission as "preserve the existing - // settings node on disk" — so a config-only save cannot silently wipe - // persisted headers / metadata / OAuth credentials. A caller editing a - // field that lives on settings (e.g. ServerConfigModal's custom headers) - // passes the full settings object it wants written, which replaces the - // node wholesale — so it must carry forward the fields it isn't editing. + // `settings` is intentionally omitted from the body. The backend route + // treats omission as "preserve the existing settings node on disk", so + // a config-only save (e.g. ServerConfigModal) cannot silently wipe + // persisted headers / metadata / OAuth credentials. To explicitly + // clear settings, send `settings: null`. const res = await doFetch( `${base}/api/servers/${encodeURIComponent(originalId)}`, { method: "PUT", headers: buildHeaders(authToken, true), - body: JSON.stringify({ id: newId, config, settings }), + body: JSON.stringify({ id: newId, config }), }, ); if (!res.ok) { From 6acaff70cab8a6cf3a820876afc73485607f253b Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 23:08:30 -0400 Subject: [PATCH 200/941] fix: honor a defined-but-empty value, and announce the expansion error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-8 review on #2035 (one inline comment, one suppressed). Both real. 1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole expression, from one defined as the empty string, which expands. The expander was calling `definedValues` on the way in, collapsing the two -- so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no way for any caller, `readResourceFromTemplate` included, to request either that or `x;q`. Key presence is now honored, including §3.2.7's rule that the matrix operator drops the `=` for an empty value (`x{;q}` -> `x;q`, while `?`/`&` keep it). Dropping blanks is a FORM concern, so it moved to the two form boundaries: the web panel seeds every declared variable with "" and the TUI's ink-form returns "" for every untouched field, so neither can tell "never touched" from "deliberately empty" -- but that is a fact about a text input, not about the template, and it has no business deciding what a template means for every other caller. Six unit tests and one integration test encoded the old collapse and now assert the corrected meaning. The prototype-collision cases get stronger rather than weaker: a blank `{?toString}` expands to `?toString=` -- its OWN empty value -- where the original bug expanded the inherited function's body. 2. The expansion error is now `role="alert"`. It can appear while typing (a pasted value that cannot be encoded), and plain text is not announced, so a screen-reader user met a disabled Read Resource with no stated reason. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 6 +- README.md | 2 + .../tui/__tests__/ResourceTestModal.test.tsx | 5 +- .../tui/src/components/ResourceTestModal.tsx | 8 ++- .../ResourceTemplatePanel.tsx | 14 ++++- .../web/src/test/core/mcp/uriTemplate.test.ts | 59 ++++++++++++++++--- .../integration/mcp/rfc6570-templates.test.ts | 11 +++- clients/web/src/utils/uriTemplate.ts | 1 + core/mcp/uriTemplate.ts | 41 ++++++++++--- 9 files changed, 126 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aa442e66..d85e29a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,11 @@ v2/main/ │ │ # tryExpandUriTemplate returns the reason as a value, and │ │ # the panel disables Read Resource rather than sending the │ │ # raw template — expandUriTemplate's raw-template fallback -│ │ # is for DISPLAY (the preview) only — #1919; +│ │ # is for DISPLAY (the preview) only. An UNDEFINED +│ │ # variable omits its expression; one defined as "" +│ │ # expands (`x{?q}` → `x?q=`), so each FORM — not the +│ │ # expander — drops its untouched blanks via +│ │ # definedValues — #1919; │ │ # modernTaskSchemas.ts: SEP-2663 modern Tasks │ │ # extension wire schemas + normalize/handle helpers, │ │ # used by the raw-wire tasks/* channel — #1631; diff --git a/README.md b/README.md index 0f29e549..3642aece 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. +An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. + Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). #### Advertised extensions diff --git a/clients/tui/__tests__/ResourceTestModal.test.tsx b/clients/tui/__tests__/ResourceTestModal.test.tsx index 26dfdc4d..525051a0 100644 --- a/clients/tui/__tests__/ResourceTestModal.test.tsx +++ b/clients/tui/__tests__/ResourceTestModal.test.tsx @@ -122,7 +122,10 @@ describe("ResourceTestModal", () => { makeTemplate({ uriTemplate: "x://{a,b}" }), { a: "only-a", b: "" }, ); - expect(read).toHaveBeenCalledWith("x://{a,b}", { a: "only-a", b: "" }); + // The untouched field is dropped before the read: `""` is a *defined* + // RFC 6570 value that would expand to a valueless pair, and ink-form + // cannot distinguish "never touched" from "deliberately empty". + expect(read).toHaveBeenCalledWith("x://{a,b}", { a: "only-a" }); unmount(); }); diff --git a/clients/tui/src/components/ResourceTestModal.tsx b/clients/tui/src/components/ResourceTestModal.tsx index 0ac77a5d..87c3ddeb 100644 --- a/clients/tui/src/components/ResourceTestModal.tsx +++ b/clients/tui/src/components/ResourceTestModal.tsx @@ -6,6 +6,7 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { ReadResourceResult } from "@modelcontextprotocol/client"; import { uriTemplateToForm } from "../utils/uriTemplateToForm.js"; import { + definedValues, requiredGroups, unmetRequiredGroups, } from "@inspector/core/mcp/uriTemplate.js"; @@ -168,9 +169,14 @@ export function ResourceTestModal({ try { // Use InspectorClient's readResourceFromTemplate method which encapsulates template expansion and resource reading + // Blanks are dropped HERE, not in the expander: a key present with `""` + // is a defined RFC 6570 value that legitimately expands to `?topic=`, + // but ink-form hands back `""` for every field the user never touched, + // so this form cannot tell the two apart. The web panel does the same at + // its own boundary. const invocation = await inspectorClient.readResourceFromTemplate( template.uriTemplate, - values, + definedValues(values), ); const duration = Date.now() - startTime; diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index cb6a1589..16db62e7 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -15,6 +15,7 @@ import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprot import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { + definedValues, hasRequiredValues, previewUriTemplate, requiredGroups, @@ -71,9 +72,15 @@ const DescriptionText = Text.withProps({ // Why Read Resource is disabled when the template itself cannot be expanded. // Without it the button is inert with nothing on screen explaining the refusal. +// +// `role="alert"` because the message can appear *while typing* -- a pasted +// value that cannot be encoded -- so a screen-reader user would otherwise meet +// a silently disabled action. An alert is an assertive live region, which is +// what announces text that arrives after first render. const ExpansionErrorText = Text.withProps({ size: "sm", c: "red", + role: "alert", }); // Left-aligned so the action sits closest to the sidebar controls / the form @@ -242,7 +249,12 @@ export function ResourceTemplatePanel({ // unencodable, and in neither case is there a URI to send -- so withhold the // request and say why, rather than reading the raw template with its braces // intact and letting the server answer with a confusing "not found". - const expansion = tryExpandUriTemplate(uriTemplate, variables); + // `definedValues` is applied here rather than inside the expander: a key + // present with `""` is a *defined* RFC 6570 value and legitimately expands to + // `?topic=`, but this form seeds every declared variable with `""`, so an + // untouched field is indistinguishable from a deliberately empty one. That is + // a fact about the form, so the form is what resolves it. + const expansion = tryExpandUriTemplate(uriTemplate, definedValues(variables)); const canSubmit = expansion.error === undefined && hasRequiredValues(groups, variables); diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 7c9d5241..596f3aae 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + definedValues, expandUriTemplate, expandUriTemplateStrict, hasRequiredValues, @@ -135,10 +136,18 @@ describe("expandUriTemplate", () => { expect(expandUriTemplate("x://{+path}", { path: "a/b" })).toBe("x://a/b"); }); - it("omits an expression whose variable was left blank", () => { - expect(expandUriTemplate("foobar://events{?topic}", { topic: "" })).toBe( + it("omits an expression whose variable is undefined", () => { + expect(expandUriTemplate("foobar://events{?topic}", {})).toBe( "foobar://events", ); + // A form's untouched field arrives as "", which is a *defined* value; the + // form drops it on the way in. See "a value defined as the empty string". + expect( + expandUriTemplate( + "foobar://events{?topic}", + definedValues({ topic: "" }), + ), + ).toBe("foobar://events"); }); it("expands each query expression independently, per RFC 6570", () => { @@ -288,9 +297,9 @@ describe("requiredGroups / hasRequiredValues", () => { const groups = requiredGroups("x://{a,b}"); expect(groups).toEqual([["a", "b"]]); expect(hasRequiredValues(groups, { a: "only-a", b: "" })).toBe(true); - expect(expandUriTemplate("x://{a,b}", { a: "only-a", b: "" })).toBe( - "x://only-a", - ); + expect( + expandUriTemplate("x://{a,b}", definedValues({ a: "only-a", b: "" })), + ).toBe("x://only-a"); }); it("rejects a multi-name expression with nothing filled", () => { @@ -480,11 +489,15 @@ describe("variable names that collide with Object.prototype", () => { // every one of them, so a *blank* field read as supplied: measured, // `({})["toString"] !== undefined` is true and its typeof is "function". it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])( - "omits a blank {?%s} instead of expanding a prototype member", + "reads a blank {?%s} as its own empty value, not a prototype member", (name) => { + // The own value is "", so the expression expands to a valueless pair -- + // NOT the inherited function's body, which is what a bare lookup gave. expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe( - "x://a", + `x://a?${name}=`, ); + // And with the key absent, the expression is omitted entirely. + expect(expandUriTemplate(`x://a{?${name}}`, {})).toBe("x://a"); }, ); @@ -628,6 +641,38 @@ describe("unmetRequiredGroups", () => { }); }); +describe("a value defined as the empty string", () => { + // RFC 6570 distinguishes an *undefined* variable (the expression is omitted) + // from one defined as "". The expander used to collapse the two, which made + // these URIs unrequestable through any caller -- `readResourceFromTemplate` + // included. Dropping blanks is a *form* concern (a text input cannot express + // "defined but empty"), so each client applies `definedValues` on its way in. + it.each([ + ["a query expression keeps the =", "x{?q}", "x?q="], + ["a continuation keeps the =", "x?a=1{&q}", "x?a=1&q="], + // RFC 6570 3.2.7: the matrix operator drops the `=` for an empty value. + ["the matrix operator drops the =", "x{;q}", "x;q"], + ["a path segment expands to a bare separator", "x{/q}", "x/"], + ["a label expands to a bare separator", "x{.q}", "x."], + ["a simple expression contributes nothing visible", "x/{q}", "x/"], + ])("%s", (_label, template, expected) => { + expect(expandUriTemplate(template, { q: "" })).toBe(expected); + }); + + it("omits the expression only when the key is absent", () => { + expect(expandUriTemplate("x{?q}", {})).toBe("x"); + expect(expandUriTemplate("x{;q}", {})).toBe("x"); + }); + + it("keeps a defined-but-empty name alongside a filled one", () => { + expect(expandUriTemplate("x{?a,b}", { a: "", b: "2" })).toBe("x?a=&b=2"); + }); + + it("definedValues is what a form applies to drop its untouched fields", () => { + expect(expandUriTemplate("x{?q}", definedValues({ q: "" }))).toBe("x"); + }); +}); + describe("tryExpandUriTemplate", () => { it("returns the URI and no error when the template expands", () => { expect( diff --git a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts index c79872c8..77b54eae 100644 --- a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts +++ b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; import { + definedValues, expandUriTemplate, tryExpandUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; @@ -144,12 +145,18 @@ describe("RFC 6570 resource templates over the wire (#1919)", () => { it("resolves the base URI a blank query expression expands to", async () => { const connected = await connectToShowcase(); - // RFC 6570 drops the whole expression when the variable is undefined, so + // RFC 6570 drops the whole expression when the variable is *undefined*, so // an unfilled `topic` legitimately requests the unfiltered collection. The + // form is what turns its untouched blank into "undefined" (`definedValues`) + // — a key present with "" is a defined value and would expand to + // `?topic=`, which is a different (and here, unregistered) resource. The // SDK's matcher compiles `{?topic}` to a *required* `\?topic=([^&]+)`, so a // template alone cannot serve this — the config registers the plain // resource, which is what a real server would do. - const uri = expandUriTemplate("foobar://events{?topic}", { topic: "" }); + const uri = expandUriTemplate( + "foobar://events{?topic}", + definedValues({ topic: "" }), + ); expect(uri).toBe("foobar://events"); const { result } = await connected.readResource(uri); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 54114c3e..941ef23c 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -17,6 +17,7 @@ import { } from "@inspector/core/mcp/uriTemplate.js"; export { + definedValues, expandUriTemplate, hasRequiredValues, parseUriTemplate, diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 5150ac37..a9278e81 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -353,6 +353,18 @@ function readValue( * Drops empty entries so an untouched optional field reads as *undefined* * (the expression disappears) rather than as the empty string (which would * expand to a valueless `?topic=`). + * + * **A UI-boundary concern, deliberately not part of the expander.** RFC 6570 + * distinguishes an undefined variable (the expression is omitted) from one + * defined as `""` (it expands — `x{?q}` gives `x?q=`, `x{;q}` gives `x;q`), and + * collapsing the two inside {@link expandUriTemplateStrict} made those URIs + * unrequestable through any caller, `readResourceFromTemplate` included. + * + * What forces the collapse is a *form*: both clients seed every declared + * variable with `""`, and a text input cannot express "defined but empty", so + * an untouched field is indistinguishable from a deliberately empty one. That + * is a property of the form, not of the template — so each form applies this + * on its way in, and the expander honors exactly what it is handed. */ export function definedValues( values: Record<string, string>, @@ -461,9 +473,17 @@ function expandExpression( const { operator } = part; if (NAMED_OPERATORS.has(operator)) { - const pairs = present.map( - ({ spec, value }) => `${spec.name}=${renderValue(value, spec, operator)}`, - ); + const pairs = present.map(({ spec, value }) => { + const rendered = renderValue(value, spec, operator); + // RFC 6570 §3.2.7: the matrix operator drops the `=` for an empty value, + // so `x{;q}` with `q = ""` is `x;q` — while `?`/`&` keep it (`x?q=`). + // Both are only reachable now that the expander honors a defined-but- + // empty value instead of collapsing it into "undefined"; see + // `definedValues` for where that collapse legitimately happens instead. + return operator === ";" && rendered.length === 0 + ? spec.name + : `${spec.name}=${rendered}`; + }); // `;` repeats its separator per pair; `?`/`&` join with `&`. return operator === ";" ? `;${pairs.join(";")}` @@ -516,9 +536,15 @@ export function expandParts( } /** - * Expands a template against the entered values per RFC 6570 — percent-encoding - * each value according to its operator, and omitting expressions whose - * variables were left blank. **Throws** on a template that is not valid. + * Expands a template against the supplied values per RFC 6570 — percent-encoding + * each value according to its operator, and omitting an expression none of + * whose variables are defined. **Throws** on a template that is not valid. + * + * A key present with an empty string is *defined*, and expands: `x{?q}` gives + * `x?q=` and `x{;q}` gives `x;q`. Only an absent key omits its expression. A + * form that cannot tell "untouched" from "deliberately empty" drops its blanks + * with {@link definedValues} before calling — which is where that judgement + * belongs, since it is a fact about the form rather than about the template. * * Every expression is expanded by {@link expandParts}; the SDK's `UriTemplate` * is constructed only because that is what rejects an unclosed expression, and @@ -535,7 +561,6 @@ export function expandUriTemplateStrict( uriTemplate: string, values: Record<string, string>, ): string { - const defined = definedValues(values); new UriTemplate(uriTemplate); const parts = parseUriTemplate(uriTemplate); const bad = parts.find((part) => part.kind === "expression" && part.invalid); @@ -546,7 +571,7 @@ export function expandUriTemplateStrict( } — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`, ); } - return expandParts(parts, defined); + return expandParts(parts, values); } /** From 52abe8a165e7eac13d945b4cd728efec51700b01 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 23:18:20 -0400 Subject: [PATCH 201/941] fix: count a pct-triplet as one character when truncating a prefix Addresses Copilot's round-9 review on #2035. RFC 6570 2.4.1 counts the prefix in *characters* and is explicit that a pct-encoded triplet counts as a single one. Truncation went by code point, so it cut a triplet in half -- measured on 6acaff70: x{+v:5} v = "%61%62%63%64%65%66" => "x%61%256" The orphaned "%6" is not a triplet, so encodeAllowReserved then (correctly) escaped its `%`, and the value went out carrying a byte the caller never asked for. Now "x%61%62%63%64%65". Triplets are units only under `+` and `#`. Under every other operator the encoder escapes a `%` anyway (`%61` -> `%2561`), so the three characters really are three and grouping them would mis-count the prefix -- asserted both ways. Astral code points remain single units under both, which is what `Array.from` was there for. Also corrects the comment on `readResourceFromTemplate`, which still said the shared helper drops blank values. It has not since 6acaff70: `params` passes through as given, so only an absent key omits its expression and a direct API caller can request `?q=` deliberately. A caller whose values come from a form drops its own blanks first. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 38 +++++++++++++++++ core/mcp/inspectorClient.ts | 16 +++++--- core/mcp/uriTemplate.ts | 41 ++++++++++++++++--- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 596f3aae..57b18154 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -641,6 +641,44 @@ describe("unmetRequiredGroups", () => { }); }); +describe("prefix modifier truncation", () => { + // RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as + // ONE. Truncating by code point cut the sixth triplet in half -- measured + // before the fix, `{+v:5}` over "%61%62%63%64%65%66" produced "x%61%256", + // because the orphaned "%6" was no longer a triplet and its `%` was then + // (correctly) encoded. A byte the caller never asked for. + it.each([ + ["+", "x{+v:5}", "x%61%62%63%64%65"], + ["#", "x{#v:5}", "x#%61%62%63%64%65"], + ])("keeps pct-triplets whole under %s", (_op, template, expected) => { + expect(expandUriTemplate(template, { v: "%61%62%63%64%65%66" })).toBe( + expected, + ); + }); + + it("counts a triplet as three characters where the encoder escapes it", () => { + // Under a non-reserved operator `%` is escaped anyway (`%61` -> `%2561`), + // so the three characters really are three and grouping them would + // mis-count the prefix. + expect(expandUriTemplate("x/{v:3}", { v: "%61%62" })).toBe("x/%2561"); + }); + + it("truncates by code point, not UTF-16 code unit", () => { + // An astral character is one character; `slice` would cut the surrogate + // pair in half and yield a lone surrogate. + expect( + expandUriTemplate("x/{v:2}", { v: "\u{1F600}\u{1F601}\u{1F602}" }), + ).toBe("x/%F0%9F%98%80%F0%9F%98%81"); + expect(expandUriTemplate("x{+v:2}", { v: "\u{1F600}\u{1F601}" })).toBe( + "x%F0%9F%98%80%F0%9F%98%81", + ); + }); + + it("leaves a value shorter than the prefix untouched", () => { + expect(expandUriTemplate("x{+v:9}", { v: "%61%62" })).toBe("x%61%62"); + }); +}); + describe("a value defined as the empty string", () => { // RFC 6570 distinguishes an *undefined* variable (the expression is omitted) // from one defined as "". The expander used to collapse the two, which made diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 2a98dc2f..6ca58821 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4972,11 +4972,17 @@ export class InspectorClient extends InspectorClientEventTarget { const uriTemplateString = uriTemplate; // Expand through the shared helper in ./uriTemplate.js so this and the web - // Resources form cannot disagree. It drops blank values so an unfilled - // optional field reads as *undefined* (the expression disappears) rather - // than as the empty string (a valueless `?topic=`), and it covers the - // expression shapes the SDK's own expander gets wrong -- `{a,b}`, `{;id}`, - // and the `{id:3}` prefix modifier (#1919). + // Resources form cannot disagree. It covers the expression shapes the SDK's + // own expander gets wrong -- `{a,b}`, `{;id}`, and the `{id:3}` prefix + // modifier (#1919). + // + // `params` is passed through AS GIVEN. Only an *absent* key omits its + // expression; a key present with `""` is a defined RFC 6570 value and + // expands (`{?q}` -> `?q=`, `{;q}` -> `;q`), which is what lets a caller + // request those URIs deliberately. A caller whose values come from a form + // -- where an untouched field is indistinguishable from a deliberately + // empty one -- drops its blanks with `definedValues` first, as the TUI's + // ResourceTestModal does. let expandedUri: string; try { expandedUri = expandUriTemplateStrict(uriTemplateString, params); diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index a9278e81..4fd7fcfd 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -434,25 +434,56 @@ function encodeUnreserved(value: string): string { ); } +/** + * The two operators whose expansion preserves RFC 3986 *reserved* characters + * and existing pct-triplets rather than encoding them (RFC 6570 §3.2.3, §3.2.4). + */ +function allowsReserved(operator: string): boolean { + return operator === "+" || operator === "#"; +} + /** Encodes one value for its operator: reserved characters survive `+` and `#`. */ function encodeValue(value: string, operator: string): string { - return operator === "+" || operator === "#" + return allowsReserved(operator) ? encodeAllowReserved(value) : encodeUnreserved(value); } +/** + * Splits a value into the units a prefix modifier counts. + * + * RFC 6570 §2.4.1 counts *characters*, and is explicit that "a pct-encoded + * triplet counts as a single character" — so `{+v:5}` over `%61%62%63%64%65%66` + * must stop after five whole triplets. Splitting on code points alone cut the + * sixth in half, and the trailing `%6` was then no longer a triplet, so + * `encodeAllowReserved` correctly encoded its `%`: the value went out as + * `%61%256`, a byte the server never asked for. + * + * Triplets are units only under `+` and `#`. Everywhere else the encoder + * escapes a `%` anyway (`%61` becomes `%2561`), so the three characters are + * three characters and grouping them would mis-count the prefix. + * + * The `u` flag makes the alternation match by code point, so an astral + * character is one unit rather than a surrogate pair that truncation could + * split — the reason `Array.from` was used here in the first place. + */ +function prefixUnits(value: string, operator: string): string[] { + if (!allowsReserved(operator)) return Array.from(value); + return value.match(/%[0-9A-Fa-f]{2}|[\s\S]/gu) ?? []; +} + /** * Applies a prefix modifier, then encodes. * - * Truncation is by *code point* (`Array.from`), not by `slice`: RFC 6570 counts - * the prefix in characters, and `String.prototype.slice` counts UTF-16 code - * units, so it can cut an astral character in half and yield a lone surrogate. + * Truncation counts the units {@link prefixUnits} defines rather than using + * `String.prototype.slice`, which counts UTF-16 code units and so can cut an + * astral character in half and yield a lone surrogate. */ function renderValue(value: string, spec: VarSpec, operator: string): string { const truncated = spec.maxLength === undefined ? value - : Array.from(value).slice(0, spec.maxLength).join(""); + : prefixUnits(value, operator).slice(0, spec.maxLength).join(""); return encodeValue(truncated, operator); } From 4b143ce043bf73ba6c1d62abe1201008be9d0d06 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 23:43:24 -0400 Subject: [PATCH 202/941] fix(scripts): validate the bin name and that its file exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #1997: - A string-form `bin` is npm's shorthand for ONE command, named after the package, so ignoring `binName` let a typo silently resolve the package's only executable while bypassing the documented "declares no such bin" failure. Match it against the manifest's unscoped name instead. - A declared bin whose file is absent (partial install) was returned unchecked. `process.execPath <missing>` still spawns and exits 1 with empty stdout, which `rawProjectListing` records as "no diagnostic captured" — reproducing the bogus every-file-uncovered report this helper exists to eliminate. Throw here, where the remedy is actionable. Also restores `rawProjectListing`'s docblock, which the new `tscEntry` block had orphaned above itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN Signed-off-by: cliffhall <cliff@futurescale.com> --- scripts/lib/resolve-node-bin.mjs | 40 ++++++++++--- scripts/lib/resolve-node-bin.test.mjs | 81 ++++++++++++++++++++++++++- scripts/lib/tsc-program.mjs | 20 +++---- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/scripts/lib/resolve-node-bin.mjs b/scripts/lib/resolve-node-bin.mjs index 528611c8..e631d3f2 100644 --- a/scripts/lib/resolve-node-bin.mjs +++ b/scripts/lib/resolve-node-bin.mjs @@ -10,7 +10,7 @@ // (no npx resolution), and pinned to the locally installed package exactly as // `npx --no-install` was. -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; @@ -24,18 +24,42 @@ import path from "node:path"; * export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")` throws * `ERR_PACKAGE_PATH_NOT_EXPORTED` (`./package.json` is always exported). * - * Throws if the package isn't installed from `fromDir` or declares no such - * bin — the caller decides whether that's a hard "cannot measure" error or a - * fallback. + * Throws if the package isn't installed from `fromDir`, declares no such bin, + * or declares one whose file is missing — the caller decides whether that's a + * hard "cannot measure" error or a fallback. */ export function resolveNodeBin(pkg, binName, fromDir) { const pkgPath = createRequire(path.join(fromDir, "package.json")).resolve( `${pkg}/package.json`, ); - const bin = JSON.parse(readFileSync(pkgPath, "utf8")).bin; - // A string-form `bin` names a single command (the package's own name). - const rel = typeof bin === "string" ? bin : bin?.[binName]; + const manifest = JSON.parse(readFileSync(pkgPath, "utf8")); + const { bin } = manifest; + // A string-form `bin` is npm's shorthand for ONE command, named after the + // package (unscoped). It does not make every requested `binName` valid, so + // match it rather than accepting whatever was asked for — otherwise a typo + // silently resolves the package's only executable instead of failing. + const rel = + typeof bin === "string" + ? unscopedName(manifest.name ?? pkg) === binName + ? bin + : undefined + : bin?.[binName]; if (typeof rel !== "string") throw new Error(`${pkg} declares no "${binName}" bin in its package.json`); - return path.join(path.dirname(pkgPath), rel); + const entry = path.join(path.dirname(pkgPath), rel); + // A declared bin whose file is absent is a partial install, and it fails + // *silently* downstream: `process.execPath <missing>` still spawns fine and + // exits 1 with nothing on stdout, which `rawProjectListing` records as "no + // diagnostic captured" and turns into the bogus every-file-uncovered report + // this helper exists to eliminate. Fail here, where the remedy is actionable. + if (!existsSync(entry)) + throw new Error( + `${pkg}'s "${binName}" bin points at a missing file: ${entry}`, + ); + return entry; +} + +/** `@scope/name` → `name`; an unscoped name is returned unchanged. */ +function unscopedName(name) { + return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name; } diff --git a/scripts/lib/resolve-node-bin.test.mjs b/scripts/lib/resolve-node-bin.test.mjs index cac917e2..6ec1e605 100644 --- a/scripts/lib/resolve-node-bin.test.mjs +++ b/scripts/lib/resolve-node-bin.test.mjs @@ -7,7 +7,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { resolveNodeBin } from "./resolve-node-bin.mjs"; @@ -46,12 +53,21 @@ test("resolves vite's bin despite Vite 8's exports map (no deep bin export)", () assert.match(entry.split(path.sep).join("/"), /\/vite\/.*vite\.js$/); }); -test("resolves a string-form bin (prettier), ignoring the binName", () => { +test("resolves a string-form bin (prettier) under the package's own name", () => { const entry = resolveNodeBin("prettier", "prettier", repoRoot); assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); assert.match(entry.split(path.sep).join("/"), /\/prettier\//); }); +test("rejects a mismatched binName against a string-form bin", () => { + // npm's string shorthand declares ONE command, named after the package — so + // a typo must fail rather than silently resolving prettier's executable. + assert.throws( + () => resolveNodeBin("prettier", "prettierd", repoRoot), + /prettier declares no "prettierd" bin/, + ); +}); + test("throws when the package is not installed from fromDir", () => { assert.throws( () => resolveNodeBin("definitely-not-installed-anywhere", "x", repoRoot), @@ -66,3 +82,64 @@ test("throws when the package declares no such bin", () => { /typescript declares no "vite" bin/, ); }); + +// The remaining cases need a manifest shape the real installs don't have, so +// they run against a throwaway node_modules tree rather than a real package. +function fixtureDir(manifest, { createBinFile = false } = {}) { + const dir = mkdtempSync(path.join(tmpdir(), "resolve-node-bin-")); + const pkgDir = path.join(dir, "node_modules", manifest.name); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify(manifest), + "utf8", + ); + if (createBinFile) { + const rel = + typeof manifest.bin === "string" + ? manifest.bin + : Object.values(manifest.bin)[0]; + const target = path.join(pkgDir, rel); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, "", "utf8"); + } + // The consumer `package.json` `createRequire` is based at. + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name: "consumer" }), + "utf8", + ); + return dir; +} + +test("throws when a declared bin's file is missing (partial install)", () => { + // The silent case this helper exists to kill: `process.execPath <missing>` + // spawns fine and exits 1 with empty stdout, which downstream reads as "no + // diagnostic captured" and reports every tracked file as uncovered. + const dir = fixtureDir({ name: "ghostpkg", bin: { ghost: "bin/ghost.js" } }); + try { + assert.throws( + () => resolveNodeBin("ghostpkg", "ghost", dir), + /ghostpkg's "ghost" bin points at a missing file/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("accepts a scoped package's string-form bin under its unscoped name", () => { + const dir = fixtureDir( + { name: "@scope/tool", bin: "bin/tool.js" }, + { createBinFile: true }, + ); + try { + const entry = resolveNodeBin("@scope/tool", "tool", dir); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); + assert.throws( + () => resolveNodeBin("@scope/tool", "scope-tool", dir), + /declares no "scope-tool" bin/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/lib/tsc-program.mjs b/scripts/lib/tsc-program.mjs index 25ee7616..7f3fc79e 100644 --- a/scripts/lib/tsc-program.mjs +++ b/scripts/lib/tsc-program.mjs @@ -191,16 +191,6 @@ export function resolveLeafProjects(clientDir, project, seen = new Set()) { ); } -/** - * Every file ONE project's program resolves, as absolute POSIX paths and with no - * filtering at all — first-party sources, `node_modules` declarations, and the - * out-of-repo `lib.*.d.ts` — plus the config diagnostic if `tsc` exited - * non-zero. The two consumers want different slices of the files, so the slicing - * happens in {@link projectSourceFiles} / {@link projectPackageFiles} rather than - * here; they differ on the `error` too, which is why it is reported rather than - * only warned about. Memoized per (client, project): the same project is listed - * by `resolveLeafProjects` and again by whichever slice the caller asks for. - */ /** * The tsc JS entry a client's programs are measured with, resolved from the * client dir up the node_modules tree exactly as `npx --no-install tsc` walked @@ -229,6 +219,16 @@ export function tscEntry(clientDir) { return entry; } +/** + * Every file ONE project's program resolves, as absolute POSIX paths and with no + * filtering at all — first-party sources, `node_modules` declarations, and the + * out-of-repo `lib.*.d.ts` — plus the config diagnostic if `tsc` exited + * non-zero. The two consumers want different slices of the files, so the slicing + * happens in {@link projectSourceFiles} / {@link projectPackageFiles} rather than + * here; they differ on the `error` too, which is why it is reported rather than + * only warned about. Memoized per (client, project): the same project is listed + * by `resolveLeafProjects` and again by whichever slice the caller asks for. + */ const listingCache = new Map(); export function rawProjectListing(clientDir, project) { const key = `${clientDir}|${project}`; From 2e06b369f4436ff484f322e74895293f7ac49326 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sun, 16 Aug 2026 23:44:45 -0400 Subject: [PATCH 203/941] fix: encode literals per RFC 6570 3.1, and label the varname tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-10 review on #2035. One inline comment, one suppressed; both real. 1. Literals were copied through verbatim, but RFC 6570 3.1 requires them pct-encoded on expansion -- the conformance case `café/{var}` expands to `caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw UTF-8 in the path; since this module replaced the SDK's expander for both clients, nothing else was going to encode it. (The pinned SDK does not either -- it returns `café/value` too -- so this is a gap we inherited rather than introduced.) `encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#` are structure) and existing pct-triplets, and encodes the rest. A BRACE passes through: it can only reach a literal from a malformed template, where an unclosed `{` becomes trailing text, and `%7B` would leave a string resembling neither the template nor anything the server can match. This is also what removed the preview's placeholder machinery. No token can survive a round trip now that literals are encoded, so the preview assembles part by part instead -- `encodeLiteral` for literal runs, `expandTemplateExpression` for filled expressions, `part.source` for the rest. Both halves still come from the shared expander, so the preview cannot promise a URI that submitting would not send, and the question of what token could never collide with a server-supplied template is gone. 2. On the varname set I took the structural point rather than the conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a separately named `TOLERATED_VARCHAR` tier, and a name needing it parses with `conforming: false`, surfaced on `TemplateVariable`. So the grammar in the code says what the RFC says and the tolerance is explicit and separately tested -- but the Inspector still expands `{default-graph-uri}`, because real servers publish hyphenated names and the SDK's matcher round-trips them. Refusing to read a resource that demonstrably works is a worse failure for a debugging tool than expanding a name the conformance suite would reject. A caller wanting RFC-exact behavior refuses on the flag rather than re-deriving the grammar. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 2 + .../web/src/test/core/mcp/uriTemplate.test.ts | 68 ++++++- clients/web/src/utils/uriTemplate.test.ts | 20 ++ clients/web/src/utils/uriTemplate.ts | 59 +++--- core/mcp/uriTemplate.ts | 172 ++++++++++++------ 5 files changed, 224 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 3286f2d9..62514114 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. +Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. + An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 57b18154..b249d638 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -54,13 +54,13 @@ describe("parseUriTemplate", () => { describe("templateVariables", () => { it("finds a simple variable and marks it required", () => { expect(templateVariables("foobar://events/{topic}")).toEqual([ - { name: "topic", operator: "", required: true }, + { name: "topic", operator: "", required: true, conforming: true }, ]); }); it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { expect(templateVariables("foobar://events{?topic}")).toEqual([ - { name: "topic", operator: "?", required: false }, + { name: "topic", operator: "?", required: false, conforming: true }, ]); }); @@ -82,7 +82,7 @@ describe("templateVariables", () => { it("deduplicates a repeated name and keeps it required if any use is", () => { expect(templateVariables("x://{?id}/{id}")).toEqual([ - { name: "id", operator: "?", required: true }, + { name: "id", operator: "?", required: true, conforming: true }, ]); }); @@ -232,7 +232,7 @@ describe("varspec modifiers", () => { // user cannot usefully fill. it("parses a prefix modifier off the variable name", () => { expect(templateVariables("x://a/{id:3}")).toEqual([ - { name: "id", operator: "", required: true }, + { name: "id", operator: "", required: true, conforming: true }, ]); }); @@ -266,7 +266,7 @@ describe("the ; (path-parameter) operator", () => { // variable named ";id" and expands to "". it("is recognised as an operator, not part of the name", () => { expect(templateVariables("x://a{;id}")).toEqual([ - { name: "id", operator: ";", required: false }, + { name: "id", operator: ";", required: false, conforming: true }, ]); }); @@ -641,6 +641,64 @@ describe("unmetRequiredGroups", () => { }); }); +describe("literal normalization", () => { + // RFC 6570 3.1: a literal may contain non-ASCII, but expansion emits it + // pct-encoded -- the conformance case `café/{var}` expands to + // `caf%C3%A9/value`. Literals used to pass through verbatim, and since this + // module replaced the SDK's expander for both clients, nothing else was + // going to encode them. (Measured: the pinned SDK returns `café/value` too.) + it("percent-encodes a non-ASCII literal", () => { + expect(expandUriTemplate("café/{var}", { var: "value" })).toBe( + "caf%C3%A9/value", + ); + }); + + it("encodes a space in a literal", () => { + expect(expandUriTemplate("a b/{v}", { v: "x" })).toBe("a%20b/x"); + }); + + it.each([ + ["URI delimiters", "x://a/b?c=d#e{?q}", { q: "1" }, "x://a/b?c=d#e?q=1"], + ["an existing pct-triplet", "a%20b/{v}", { v: "x" }, "a%20b/x"], + ])("preserves %s", (_label, template, values, expected) => { + expect(expandUriTemplate(template, values)).toBe(expected); + }); + + it("passes a brace through, so a malformed template stays legible", () => { + // A brace reaches a literal only from a malformed template (an unclosed + // `{` becomes trailing text). Encoding it to %7B would leave a string + // resembling neither the template nor anything the server could match. + expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops"); + }); +}); + +describe("names outside RFC 6570 varchar", () => { + // The conformance suite rejects `{~thing}` and `{default-graph-uri}`. The + // Inspector expands them anyway -- real servers publish hyphenated names and + // the SDK's matcher round-trips them -- but the tolerance is labelled rather + // than folded into the grammar, so a caller wanting RFC-exact behavior can + // refuse on the flag. + it.each(["user-id", "a~b"])("expands the tolerated name %s", (name) => { + expect(expandUriTemplate(`x://{${name}}`, { [name]: "7" })).toBe("x://7"); + }); + + it.each([ + ["default-graph-uri", false], + ["~thing", false], + ["plain_name", true], + ["a.b", true], + ["%61%62", true], + ])("marks %s conforming=%s", (name, conforming) => { + expect(templateVariables(`x://{${name}}`)[0].conforming).toBe(conforming); + }); + + it("marks a name non-conforming if any occurrence needed the tolerance", () => { + expect(templateVariables("x://{user-id}{?user-id}")[0].conforming).toBe( + false, + ); + }); +}); + describe("prefix modifier truncation", () => { // RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as // ONE. Truncating by code point cut the sixth triplet in half -- measured diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index 3e9ca93b..f25c9e73 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -79,3 +79,23 @@ describe("preview with Object.prototype-colliding names", () => { ); }); }); + +describe("previewUriTemplate - literals", () => { + // The preview promises the URI that submitting would send, so it applies the + // same RFC 6570 3.1 literal encoding the wire does. + it("percent-encodes a non-ASCII literal", () => { + expect(previewUriTemplate("café/{var}", { var: "value" })).toBe( + "caf%C3%A9/value", + ); + }); + + it("encodes the literal even while an expression is still unfilled", () => { + expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}"); + }); + + it("keeps a malformed template legible rather than encoding its brace", () => { + expect(previewUriTemplate("x://café/{oops", {})).toBe( + "x://caf%C3%A9/{oops", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 941ef23c..78db1bc9 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -12,12 +12,14 @@ import { definedValues, - expandUriTemplate, + encodeLiteral, + expandTemplateExpression, parseUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; export { definedValues, + encodeLiteral, expandUriTemplate, hasRequiredValues, parseUriTemplate, @@ -33,56 +35,37 @@ export type { } from "@inspector/core/mcp/uriTemplate.js"; /** - * A placeholder standing in for an expression the user has not filled in yet. + * A partially-expanded template for display: an expression with at least one + * value is expanded exactly as the wire would render it, and the rest are left + * standing as written so the user can see what is still needed. * - * `U+0000` cannot appear in a URI template, so a token built from it can never - * collide with real template text; and because it is emitted as *literal* text - * rather than as a variable value, expansion passes it through unencoded. - */ -const deferredToken = (index: number) => `\u0000${index}\u0000`; - -/** - * A partially-expanded template for display: expressions with at least one - * value are expanded exactly as `expandUriTemplate` would, and the rest are - * left standing as written so the user can see what is still needed. + * Assembled part by part rather than by rewriting the template and re-parsing + * it. The rewrite approach needed a placeholder to stand in for each unexpanded + * expression, and a placeholder is exactly what cannot survive the round trip + * now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever + * token stood in for `{?topic}` would come back encoded and no longer match. + * Going part by part removes the token, and with it the question of what text + * could never collide with a server-supplied template. * - * Unfilled expressions are swapped for an inert token and restored afterwards, - * and the rewritten template is expanded by `expandUriTemplate` itself. Routing - * it back through the real expander is what keeps the preview honest: it cannot - * promise a URI that submitting would not send. (Expansion carries no - * cross-expression state — see `expandParts` — so this is a per-expression - * substitution, not a whole-template rewrite that some later pass depends on.) + * Each half still comes from the shared expander -- `encodeLiteral` for literal + * runs, `expandTemplateExpression` for filled ones -- so the preview cannot + * promise a URI that submitting would not send. */ export function previewUriTemplate( uriTemplate: string, values: Record<string, string>, ): string { - const parts = parseUriTemplate(uriTemplate); - const deferred: string[] = []; const defined = definedValues(values); - const rewritten = parts + return parseUriTemplate(uriTemplate) .map((part) => { - if (part.kind === "literal") return part.text; + if (part.kind === "literal") return encodeLiteral(part.text); // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are // valid RFC 6570 variable names, and a plain lookup would find // `Object.prototype`'s member and treat a blank field as filled. - if (part.names.some((name) => Object.hasOwn(defined, name))) { - return part.source; - } - deferred.push(part.source); - return deferredToken(deferred.length - 1); + return part.names.some((name) => Object.hasOwn(defined, name)) + ? expandTemplateExpression(part, defined) + : part.source; }) .join(""); - - const expanded = expandUriTemplate(rewritten, defined); - - // Restored by exact-string replacement rather than by a pattern: a regex - // matching the token would have to embed U+0000 literally, which `eslint` - // rejects (`no-control-regex`) -- and each token's text is already known - // here, so there is nothing to match on. - return deferred.reduce( - (uri, source, index) => uri.replaceAll(deferredToken(index), source), - expanded, - ); } diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 4fd7fcfd..cbcaffc4 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -69,6 +69,14 @@ const NAMED_OPERATORS = new Set([";", "?", "&"]); /** A single variable reference inside an expression, e.g. `id` or `id:3`. */ export interface VarSpec { name: string; + /** + * Absent (i.e. conforming) unless the name needed the `-`/`~` tolerance to + * parse — see {@link TOLERATED_VARCHAR}. Present and `false` marks a name RFC + * 6570's own conformance suite would reject, which the Inspector expands + * anyway because real servers publish such names and the SDK's matcher + * accepts them. + */ + conforming?: false; /** * The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*. * Applied to the value before percent-encoding, per §3.2.1. @@ -107,6 +115,13 @@ export interface TemplateVariable { name: string; /** The operator of the expression the variable was first seen in. */ operator: string; + /** + * `false` when the name is outside RFC 6570's `varchar` and only parsed via + * the documented `-`/`~` tolerance (see {@link TOLERATED_VARCHAR}). A caller + * wanting RFC-exact behavior refuses on this rather than re-deriving the + * grammar; the Inspector itself expands such a template. + */ + conforming: boolean; /** * True when this variable appears in at least one expression that cannot be * omitted without changing the URI's structure — see @@ -122,37 +137,51 @@ export interface TemplateVariable { } /** - * One character of a variable's name. + * RFC 6570's `varchar`, exactly: `ALPHA / DIGIT / "_" / pct-encoded`. * - * RFC 6570's `varchar` is `ALPHA / DIGIT / "_" / pct-encoded`. This admits two - * more — `-` and `~` — and the widening is deliberate, on a rule the RFC's own - * set is a subset of: **a name is the one thing expansion emits into the URI - * without encoding it** (the `;`, `?` and `&` operators write `name=value`), so - * a name must consist of characters that need no encoding. That is RFC 3986's - * *unreserved* set — `ALPHA / DIGIT / -._~` — plus pct-encoded. - * - * Strict `varchar` would refuse `{user-id}`, which real servers publish and - * which the SDK's own matcher round-trips (`match()` compiles the hyphen - * literally). Refusing to read a resource that demonstrably works is a worse - * outcome for a debugging tool than tolerating a name one character outside the - * production — whereas everything this *does* reject is a shape we would - * otherwise have to **guess** at, which is the line the rest of this module - * draws too. + * This is the *conformance* production, kept unwidened so the grammar in the + * code says what the RFC says. What the parser goes on to **accept** is one + * tier wider — see {@link TOLERATED_VARCHAR}. */ -const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`; +const VARCHAR = String.raw`(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})`; + +/** + * The two characters accepted in a name beyond `varchar`, as an explicitly + * separate tier rather than a widened grammar. + * + * `-` and `~` are RFC 3986 *unreserved*, so they satisfy the property that + * actually matters at expansion time: a name is the one thing emitted into the + * URI **without** encoding (`;`, `?` and `&` write `name=value`), and these two + * need none. RFC 6570's own conformance suite rejects `{~thing}` and + * `{default-graph-uri}`, so a template using them is non-conforming — but real + * servers publish hyphenated names, and the SDK's matcher round-trips them + * (`match()` compiles the hyphen literally). Refusing to read a resource that + * demonstrably works is a worse failure for a debugging tool than expanding a + * name one character outside the production. + * + * So the tolerance is *labelled* rather than hidden: a varspec matching only + * this tier is parsed with `conforming: false`, surfaced on + * {@link TemplateVariable}, and a caller that wants RFC-exact behavior can + * refuse on that flag without re-implementing the grammar. + */ +const TOLERATED_VARCHAR = String.raw`[-~]`; + +/** `varname = varchar *( ["."] varchar )` — a dot separates, never leads or doubles. */ +function varnameGrammar(varchar: string): string { + return `${varchar}(?:\\.?${varchar})*`; +} /** * RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either * the explode `*` or a `:max-length`, never both. * - * `varname = varchar *( ["."] varchar )`, so a `.` may separate name segments - * but cannot lead, trail, or double. `max-length` is `%x31-39 0*3DIGIT` — 1 to - * 9999, no leading zero — so `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are - * invalid *templates*, not templates with an ignorable modifier. + * `max-length` is `%x31-39 0*3DIGIT` — 1 to 9999, no leading zero — so `{id:}`, + * `{id:0}`, `{id:abc}` and `{id:10000}` are invalid *templates*, not templates + * with an ignorable modifier. * * Anchoring the whole varspec is what rejects the shapes a looser "strip the - * `*`, split on `:`" pass waves through. Measured against this branch before - * the change, each expanded rather than being refused: + * `*`, split on `:`" pass waves through. Measured before it landed, each + * expanded rather than being refused: * * | Varspec | Was | Why it is invalid | * | --------- | -------------------- | ----------------------------------- | @@ -161,39 +190,24 @@ const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`; * | `{ id }` | name `id` | whitespace is not a `varchar` | * | `{a b}` | name `a b` | ditto — and it emits into the URI | */ -const VARSPEC_GRAMMAR = new RegExp( - `^(${VARCHAR}(?:\\.?${VARCHAR})*)(?:\\*|:([1-9][0-9]{0,3}))?$`, +const MODIFIER = String.raw`(?:\*|:([1-9][0-9]{0,3}))?`; +const CONFORMING_VARSPEC = new RegExp( + `^(${varnameGrammar(VARCHAR)})${MODIFIER}$`, +); +const TOLERATED_VARSPEC = new RegExp( + `^(${varnameGrammar(`(?:${VARCHAR}|${TOLERATED_VARCHAR})`)})${MODIFIER}$`, ); -/** - * Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix, - * or `"invalid"` for anything {@link VARSPEC_GRAMMAR} does not admit. - * - * Three classes reach that verdict, none of them ignorable: - * - * - **A modifier outside the grammar** (`{id:abc}`), or two at once (`{id*:3}`), - * or one in the wrong position (`{*id}`). Treating any of them as a plain - * `{id}` would send a URI that does not match what the server advertised, - * with nothing to alert anyone — and `{id*:3}` silently *truncated* the value. - * - **A name outside the character set** (`{a b}`, `{a/b}`). A named operator - * emits the name verbatim, so this is the one place an unencodable character - * reaches the URI as structure. - * - **An empty varspec** — `{}`, `{,}`, `{a,}`, `{?}`, `{*}`. RFC 6570 requires - * at least one varspec per expression and admits no empty member, so this is - * a malformed *template*, not an expression with a member to skip. Skipping - * it is the more dangerous reading: `x://{}` would expand to `x://` while the - * form rendered no inputs, so its "everything required is filled" check would - * be vacuously true and it would submit a URI that is not the template the - * server published. - * - * The explode modifier parses but is not honored: it only changes how a list or - * map value is joined, and every value reaching this module is a single string. - */ function parseVarSpec(raw: string): VarSpec | "invalid" { - const match = VARSPEC_GRAMMAR.exec(raw); + const conforming = CONFORMING_VARSPEC.exec(raw); + const match = conforming ?? TOLERATED_VARSPEC.exec(raw); if (match === null) return "invalid"; const [, name, maxLength] = match; - return maxLength === undefined ? { name } : { name, maxLength: +maxLength }; + return { + name, + ...(maxLength === undefined ? {} : { maxLength: +maxLength }), + ...(conforming === null ? { conforming: false } : {}), + }; } /** @@ -257,11 +271,20 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { if (part.kind !== "expression") continue; const required = !OMITTABLE_OPERATORS.has(part.operator); for (const name of part.names) { + const conforming = !part.varspecs.some( + (spec) => spec.name === name && spec.conforming === false, + ); const existing = byName.get(name); if (existing) { existing.required = existing.required || required; + existing.conforming = existing.conforming && conforming; } else { - byName.set(name, { name, operator: part.operator, required }); + byName.set(name, { + name, + operator: part.operator, + required, + conforming, + }); } } } @@ -398,6 +421,37 @@ const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu; * `%25`. The `u` flag makes the class match by code point, so an astral * character is handed to `encodeURIComponent` whole rather than as surrogates. */ +/** + * Percent-encodes a template's **literal** text (RFC 6570 §3.1). + * + * A literal may legally contain non-ASCII (`ucschar`), but expansion must emit + * it pct-encoded: the conformance case `café/{var}` expands to + * `caf%C3%A9/value`. Literals used to be copied through verbatim, so such a + * template went out with raw UTF-8 in the path — and since this module replaced + * the SDK's expander for both clients, nothing else was going to encode it. (The + * pinned SDK does not either; measured, it also returns `café/value`.) + * + * The rule is the same one {@link encodeAllowReserved} implements — keep + * unreserved, keep reserved (a literal's `/`, `?` and `#` are *structure*, not + * data), keep an existing pct-triplet, encode everything else — with one + * exception: a brace passes through. + * + * A brace is not a legal literal character, so it reaches this function only + * from a **malformed** template, where {@link parseUriTemplate} treats an + * unclosed `{` as trailing text. That case degrades to "show the template as + * the server wrote it", and encoding the brace to `%7B` would make the + * degradation unreadable — a string resembling neither the template nor + * anything the server could match. + */ +export function encodeLiteral(text: string): string { + return text + .split(/([{}])/g) + .map((chunk, index) => + index % 2 === 1 ? chunk : encodeAllowReserved(chunk), + ) + .join(""); +} + function encodeAllowReserved(value: string): string { return value .split(/(%[0-9A-Fa-f]{2})/g) @@ -487,8 +541,16 @@ function renderValue(value: string, spec: VarSpec, operator: string): string { return encodeValue(truncated, operator); } -/** Expands one expression per RFC 6570. Returns "" if no name has a value. */ -function expandExpression( +/** + * Expands one expression per RFC 6570. Returns "" if no name has a value. + * + * Exported for the web client's URI *preview*, which expands the expressions + * the user has filled while leaving the rest standing as written — it cannot + * go through {@link expandParts}, which expands all or nothing, and routing a + * half-rewritten template string back through the parser would mean smuggling + * a placeholder past {@link encodeLiteral}. + */ +export function expandTemplateExpression( part: TemplateExpression, values: Record<string, string>, ): string { @@ -561,7 +623,9 @@ export function expandParts( ): string { return parts .map((part) => - part.kind === "literal" ? part.text : expandExpression(part, values), + part.kind === "literal" + ? encodeLiteral(part.text) + : expandTemplateExpression(part, values), ) .join(""); } From b399bc64e14487a2e4bb9bbef4bc01f70062550f Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:01:51 -0400 Subject: [PATCH 204/941] fix(scripts): find the manifest without exports, quote shell args on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 2 (suppressed comments), all three real: - `require.resolve("<pkg>/package.json")` is governed by the package's `exports`, and Node keeps no special case for `./package.json` — so a package declaring an `exports` map without it threw ERR_PACKAGE_PATH_NOT_EXPORTED even though its bin was installed and spawnable, a false "not installed" pointing at `npm install` for a package already on disk. Walk `resolve.paths()` instead, which never consults `exports`. The docblock claiming the opposite is corrected. - `shell: true` (needed at all on Windows to start the `.cmd` shims) makes Node hand `cmd.exe` one space-joined string, so an argument holding a path with a space splits in two. Every generated path in pack-and-verify lives under `tmpdir()`, which sits beneath the user profile. Quote them via `shellArgs()` at all four spawn sites, the command path included — the hazard predates this PR, so fix the class. - Document the new module: `scripts/lib/resolve-node-bin.mjs` in AGENTS.md's scripts/ tree, its tests in the README's `test:scripts` row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 7 ++++- README.md | 2 +- scripts/lib/resolve-node-bin.mjs | 37 +++++++++++++++++----- scripts/lib/resolve-node-bin.test.mjs | 21 +++++++++++++ scripts/pack-and-verify.mjs | 44 +++++++++++++++++++++------ 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ba943ec..4d901292 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,12 @@ v2/main/ │ # pack-and-verify.mjs, and lib/ shared helpers │ # (tsc-program.mjs is the `tsc --listFilesOnly` │ # measurement both coverage guards read a program -│ # through — #1965). Prettier-gated via +│ # through — #1965; resolve-node-bin.mjs resolves a +│ # package's bin through its own package.json so the +│ # verify/smoke/pack scripts spawn it with +│ # `process.execPath` instead of the `npx`/`npm` +│ # `.cmd` shims Node refuses to spawn shell-free on +│ # Windows — #1939). Prettier-gated via │ # `format:check:scripts`; its own pure parsers are │ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification diff --git a/README.md b/README.md index e3330d41..502fe076 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | | `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | | `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. | `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | diff --git a/scripts/lib/resolve-node-bin.mjs b/scripts/lib/resolve-node-bin.mjs index e631d3f2..33d223ae 100644 --- a/scripts/lib/resolve-node-bin.mjs +++ b/scripts/lib/resolve-node-bin.mjs @@ -18,20 +18,18 @@ import path from "node:path"; * Absolute path of the JS entry behind a package's bin (e.g. typescript's * `tsc`, vite's `vite`), resolved from `fromDir` up the node_modules tree — * the same walk `npx --no-install` does, minus the `.cmd` shim a shell-free - * spawn can't start on Windows. Resolves `<pkg>/package.json` and reads its - * `bin` field (what npx itself does) rather than resolving the bin path - * directly, because an `exports` map blocks deep resolution — Vite 8 doesn't - * export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")` throws - * `ERR_PACKAGE_PATH_NOT_EXPORTED` (`./package.json` is always exported). + * spawn can't start on Windows. Reads the package's manifest and takes the + * path out of its `bin` field (what npx itself does) rather than resolving the + * bin path directly, because an `exports` map blocks deep resolution — Vite 8 + * doesn't export `./bin/vite.js`, so `require.resolve("vite/bin/vite.js")` + * throws `ERR_PACKAGE_PATH_NOT_EXPORTED`. * * Throws if the package isn't installed from `fromDir`, declares no such bin, * or declares one whose file is missing — the caller decides whether that's a * hard "cannot measure" error or a fallback. */ export function resolveNodeBin(pkg, binName, fromDir) { - const pkgPath = createRequire(path.join(fromDir, "package.json")).resolve( - `${pkg}/package.json`, - ); + const pkgPath = resolveManifest(pkg, fromDir); const manifest = JSON.parse(readFileSync(pkgPath, "utf8")); const { bin } = manifest; // A string-form `bin` is npm's shorthand for ONE command, named after the @@ -59,6 +57,29 @@ export function resolveNodeBin(pkg, binName, fromDir) { return entry; } +/** + * The package's own `package.json`, found by walking the same `node_modules` + * chain Node's resolver would from `fromDir` — deliberately NOT via + * `require.resolve("<pkg>/package.json")`. + * + * That shortcut reads as safe and isn't: subpath resolution is governed by the + * package's `exports`, and a package may declare one that omits `./package.json` + * (Node dropped its special case for it, so there is no guaranteed export). Such + * a package throws `ERR_PACKAGE_PATH_NOT_EXPORTED` here even though its bin is + * installed and perfectly spawnable — a false "not installed" that would send + * someone to `npm install` for a package already on disk. `resolve.paths()` + * gives the search dirs without consulting `exports` at all. + */ +function resolveManifest(pkg, fromDir) { + const require = createRequire(path.join(fromDir, "package.json")); + // `resolve.paths` returns null only for builtins, which have no bin to find. + for (const dir of require.resolve.paths(pkg) ?? []) { + const candidate = path.join(dir, ...pkg.split("/"), "package.json"); + if (existsSync(candidate)) return candidate; + } + throw new Error(`cannot find ${pkg} from ${fromDir}`); +} + /** `@scope/name` → `name`; an unscoped name is returned unchanged. */ function unscopedName(name) { return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name; diff --git a/scripts/lib/resolve-node-bin.test.mjs b/scripts/lib/resolve-node-bin.test.mjs index 6ec1e605..e62a72ed 100644 --- a/scripts/lib/resolve-node-bin.test.mjs +++ b/scripts/lib/resolve-node-bin.test.mjs @@ -127,6 +127,27 @@ test("throws when a declared bin's file is missing (partial install)", () => { } }); +test("resolves a package whose `exports` hides ./package.json", () => { + // `require.resolve("<pkg>/package.json")` throws ERR_PACKAGE_PATH_NOT_EXPORTED + // for this shape — Node has no special case keeping `./package.json` + // exported — which would be a false "not installed" for a package whose bin + // is right there on disk. The manifest lookup walks node_modules instead. + const dir = fixtureDir( + { + name: "walledpkg", + exports: { ".": "./index.js" }, + bin: { walled: "bin/walled.js" }, + }, + { createBinFile: true }, + ); + try { + const entry = resolveNodeBin("walledpkg", "walled", dir); + assert.ok(existsSync(entry), `resolved entry does not exist: ${entry}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("accepts a scoped package's string-form bin under its unscoped name", () => { const dir = fixtureDir( { name: "@scope/tool", bin: "bin/tool.js" }, diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 2cd4494d..7a0deedf 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -98,12 +98,28 @@ function step(message) { console.log(`\npack:verify — ${message}`); } +// Windows needs `shell: true` to start the `npm`/`npx` `.cmd` shims at all +// (#1939), and with a shell Node hands the argv to `cmd.exe` as ONE +// space-joined string — so an argument holding a path with a space is split +// into two. That is not hypothetical here: every generated path in this file +// lives under `tmpdir()`, which on Windows sits beneath the user profile and +// so contains a space whenever the account name does. Quote each argument +// that needs it; `cmd.exe` (and the CRT parser behind it) strips the quotes +// back off, so the child sees exactly the argument we passed. +const WIN_SHELL = process.platform === "win32"; +function shellArgs(args) { + if (!WIN_SHELL) return args; + return args.map((arg) => + /[\s&|<>^"]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg, + ); +} + /** Run a command to completion, inheriting stdio. Returns the exit status. */ function runInherit(command, args, cwd = repoRoot) { - const r = spawnSync(command, args, { + const r = spawnSync(command, shellArgs(args), { cwd, stdio: "inherit", - shell: process.platform === "win32", + shell: WIN_SHELL, }); return r.status; } @@ -136,10 +152,17 @@ if (runInherit("npm", ["run", "build"]) !== 0) { step("packing the publishable tarball (npm pack)..."); const pack = spawnSync( "npm", - ["pack", "--json", "--ignore-scripts", "--pack-destination", tmpdir()], // npm is npm.cmd on Windows, which needs a shell to resolve (#1939) — the - // same idiom as runInherit/runBin below. - { cwd: repoRoot, encoding: "utf8", shell: process.platform === "win32" }, + // same idiom as runInherit/runBin. `shellArgs` quotes `tmpdir()`, which the + // shell would otherwise split on a space in the user profile path. + shellArgs([ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + tmpdir(), + ]), + { cwd: repoRoot, encoding: "utf8", shell: WIN_SHELL }, ); if (pack.status !== 0) { fail(`\`npm pack\` failed:\n${pack.stderr || pack.stdout}`); @@ -253,11 +276,13 @@ try { /** Run the installed bin. Returns { status, output }. */ const runBin = (args, extraEnv = {}) => { - const r = spawnSync(bin, args, { + // `bin` itself is quoted too: it lives under the throwaway consumer's + // `tmpdir()` path, and with a shell the command is joined with the argv. + const r = spawnSync(shellArgs([bin])[0], shellArgs(args), { cwd: work, encoding: "utf8", env: { ...process.env, ...extraEnv }, - shell: process.platform === "win32", + shell: WIN_SHELL, }); return { status: r.status, @@ -378,7 +403,8 @@ async function verifyWeb(bin, cwd) { const host = "127.0.0.1"; const port = process.env.PACK_VERIFY_WEB_PORT ?? "6399"; const token = "pack-verify-token"; - const child = spawn(bin, ["--web"], { + // Quoted for the same reason as runBin: `bin` sits under a `tmpdir()` path. + const child = spawn(shellArgs([bin])[0], ["--web"], { cwd, env: { ...process.env, @@ -388,7 +414,7 @@ async function verifyWeb(bin, cwd) { MCP_AUTO_OPEN_ENABLED: "false", }, stdio: ["ignore", "inherit", "inherit"], - shell: process.platform === "win32", + shell: WIN_SHELL, }); // Expose the child so fail() can kill it if a check below exits the process // (process.exit skips the `finally { stop() }`). From ab40fb621ad78a4553da733dad52c036c2a04115 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:02:13 -0400 Subject: [PATCH 205/941] fix(web): key tool selection by row identity, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1961 keyed the sidebar's React children by source position, but everything downstream still identified a tool by `tool.name`. With a `tools/list` that repeats a name, both copies highlighted at once, the click guard made the other copy a no-op, and `tools.find((t) => t.name === …)` always resolved to the first — so a later copy rendered but could never be opened. Thread the row key through selection instead: - `utils/toolUtils.ts` now owns `toolRowKey` (moved out of ToolControls) and adds `findToolByRowKey`, which compares computed keys rather than parsing one so the two can't drift. - `ToolControls`: `selectedName` → `selectedKey`; the highlight and the click guard compare keys, and `onSelectTool` emits the row key. - `ToolsScreen`: `ToolsUiState.selectedToolName` → `selectedToolKey`, with both lookups going through `findToolByRowKey`. Renamed through `screenUiState.ts` and `App.tsx`'s `onToolsUiChange`. `onCallTool` still sends `selectedTool.name` — the duplicated name is the genuine wire identity; only the UI identity needed to be unique. Also prefixes the excluded (SEP-2243) rows' keys: they render into the same `Stack` as the list rows but index within their own list, so an excluded tool sharing a name and index with a listed one collided in the key space. Closes #2001 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.test.tsx | 12 ++--- clients/web/src/App.tsx | 8 ++-- .../ToolControls/ToolControls.stories.tsx | 3 +- .../groups/ToolControls/ToolControls.test.tsx | 38 ++++++++++++++- .../groups/ToolControls/ToolControls.tsx | 36 +++++++++----- .../screens/ToolsScreen/ToolsScreen.test.tsx | 48 +++++++++++++++++-- .../screens/ToolsScreen/ToolsScreen.tsx | 32 ++++++++----- .../src/components/screens/screenUiState.ts | 2 +- clients/web/src/lib/oauthResume.test.ts | 6 +-- clients/web/src/utils/toolUtils.test.ts | 41 +++++++++++++++- clients/web/src/utils/toolUtils.ts | 32 +++++++++++++ 11 files changed, 210 insertions(+), 48 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 8ca842ed..f9b2b921 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -363,7 +363,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ InspectorView: (props: { toolCallState?: { status?: string }; toolsUi?: { - selectedToolName?: string; + selectedToolKey?: string; formValues: Record<string, unknown>; search: string; }; @@ -384,7 +384,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ onConnectionInfo: () => void; onToggleConnection: (id: string) => void; onToolsUiChange: (next: { - selectedToolName?: string; + selectedToolKey?: string; formValues: Record<string, unknown>; search: string; }) => void; @@ -433,7 +433,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ {Object.keys(props.progressByTaskId ?? {}).join(",") || "none"} </span> <span data-testid="selected-tool"> - {props.toolsUi?.selectedToolName ?? "none"} + {props.toolsUi?.selectedToolKey ?? "none"} </span> <span data-testid="tool-search">{props.toolsUi?.search || "none"}</span> <span data-testid="selected-prompt"> @@ -469,7 +469,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ formValues: {}, search: "", ...props.toolsUi, - selectedToolName: "get_acts", + selectedToolKey: "0:get_acts", }) } > @@ -481,7 +481,7 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({ formValues: {}, search: "", ...props.toolsUi, - selectedToolName: "other_tool", + selectedToolKey: "1:other_tool", }) } > @@ -869,7 +869,7 @@ describe("App session-scoped state reset on disconnect", () => { expect(screen.getByTestId("tool-status")).toHaveTextContent("ok"), ); - // A search keystroke leaves `selectedToolName` unchanged, so the result + // A search keystroke leaves `selectedToolKey` unchanged, so the result // stays put. await user.click(screen.getByText("set-tool-search")); await waitFor(() => diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 5837f27f..91055747 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -3160,17 +3160,17 @@ function App() { // drops the previous tool's result — the result panel renders `toolCallState` // regardless of selection, so without this a stale result would linger under // the newly-selected tool (which has no result of its own yet). Search and - // form edits keep `selectedToolName` unchanged, so they leave the result be. - // Depends on `selectedToolName` only (not the whole `toolsUi`), so a search + // form edits keep `selectedToolKey` unchanged, so they leave the result be. + // Depends on `selectedToolKey` only (not the whole `toolsUi`), so a search // keystroke doesn't churn the callback identity. const onToolsUiChange = useCallback( (next: ToolsUiState) => { - if (next.selectedToolName !== toolsUi.selectedToolName) { + if (next.selectedToolKey !== toolsUi.selectedToolKey) { setToolCallState(undefined); } setToolsUi(next); }, - [toolsUi.selectedToolName], + [toolsUi.selectedToolKey], ); // --- MCP Apps handlers. Unlike onCallTool (which feeds the Tools panel), diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx index 23709132..9989e9ff 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.stories.tsx @@ -45,7 +45,8 @@ export const Default: Story = { export const WithSelection: Story = { args: { tools: longToolList, - selectedName: "query_database", + // Row identity is `index:name`, so the selection names the position too. + selectedKey: "1:query_database", }, }; diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx index b6e379e8..a9a17698 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.test.tsx @@ -83,7 +83,7 @@ describe("ToolControls", () => { <ToolControls {...baseProps} onSelectTool={onSelectTool} />, ); await user.click(screen.getByText("git_status")); - expect(onSelectTool).toHaveBeenCalledWith("git_status"); + expect(onSelectTool).toHaveBeenCalledWith("2:git_status"); }); it("does not invoke onSelectTool when the already-selected tool is clicked", async () => { @@ -92,7 +92,7 @@ describe("ToolControls", () => { renderWithMantine( <ToolControls {...baseProps} - selectedName="git_status" + selectedKey="2:git_status" onSelectTool={onSelectTool} />, ); @@ -100,6 +100,40 @@ describe("ToolControls", () => { expect(onSelectTool).not.toHaveBeenCalled(); }); + it("selects each copy of a duplicated tool name independently", async () => { + // A `tools/list` may repeat a name. Selection is keyed by row identity, so + // selecting the first copy must leave the second clickable and unhighlighted + // (#2001 — with a name-keyed selection both highlighted and neither of the + // later copies could be opened). + const user = userEvent.setup(); + const onSelectTool = vi.fn(); + const duplicated: Tool[] = [ + { name: "get_weather", inputSchema: { type: "object" } }, + { name: "echo", inputSchema: { type: "object" } }, + { name: "get_weather", inputSchema: { type: "object" } }, + ]; + renderWithMantine( + <ToolControls + {...baseProps} + tools={duplicated} + selectedKey="0:get_weather" + onSelectTool={onSelectTool} + />, + ); + const rows = screen.getAllByText("get_weather"); + expect(rows).toHaveLength(2); + // Only the selected row carries the highlight background. + const highlighted = rows + .map((row) => row.closest("button")) + .filter((button) => button?.style.background !== ""); + expect(highlighted).toHaveLength(1); + expect(highlighted[0]).toBe(rows[0]?.closest("button")); + + // The second copy is still clickable, and reports its own row key. + await user.click(rows[1] as HTMLElement); + expect(onSelectTool).toHaveBeenCalledWith("2:get_weather"); + }); + it("does not show the list-changed indicator when listChanged is false", () => { renderWithMantine(<ToolControls {...baseProps} />); expect(screen.queryByText("List updated")).not.toBeInTheDocument(); diff --git a/clients/web/src/components/groups/ToolControls/ToolControls.tsx b/clients/web/src/components/groups/ToolControls/ToolControls.tsx index 01ad1f04..92adc2d5 100644 --- a/clients/web/src/components/groups/ToolControls/ToolControls.tsx +++ b/clients/web/src/components/groups/ToolControls/ToolControls.tsx @@ -23,13 +23,19 @@ import { } from "../../elements/ListPaginationControls/ListPaginationControls"; import { ToolListItem } from "../ToolListItem/ToolListItem"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; +import { toolRowKey } from "../../../utils/toolUtils"; export interface ToolControlsProps { tools: Tool[]; /** Tools the SDK excluded from `tools/list` for invalid `x-mcp-header` * annotations (SEP-2243), shown below the list with the reason (#1632). */ excludedTools?: ExcludedTool[]; - selectedName?: string; + /** + * The selected row's {@link toolRowKey}, not its name — a `tools/list` may + * repeat a name, and a name-keyed selection would highlight every copy at + * once and make the others unclickable (#2001). + */ + selectedKey?: string; // Search text is controlled by the parent (App, via ToolsScreen) so it // persists across tab navigation within a live session — see #1417. searchText?: string; @@ -49,7 +55,8 @@ export interface ToolControlsProps { /** Pagination controls (#1721). */ pagination: ListPaginationControlsProps; onSearchChange: (value: string) => void; - onSelectTool: (name: string) => void; + /** Emits the clicked row's {@link toolRowKey} (see `selectedKey`). */ + onSelectTool: (key: string) => void; } // One excluded tool: a warning icon, the tool name (struck through, since it is @@ -116,12 +123,12 @@ const ExcludedTooltip = Tooltip.withProps({ position: "right", }); -// A server may return the same tool name more than once, so the name alone is -// not a unique React key — colliding keys let a filtered-out row survive -// reconciliation instead of unmounting (#1957). The tool's position in the -// unfiltered list disambiguates duplicates and stays stable while the search -// narrows, since it is captured before filtering. -const rowKey = (name: string, sourceIndex: number) => `${sourceIndex}:${name}`; +// Excluded rows render into the same `Stack` as the list rows above them, so +// their keys share one namespace — and an excluded tool's index is counted +// within its own list, which would collide with a listed tool of the same name +// at the same index. The prefix keeps the two spaces apart. +const excludedRowKey = (name: string, sourceIndex: number) => + `excluded:${toolRowKey(name, sourceIndex)}`; /** Matches a tool against the (already lower-cased) search query by name or title. */ const matchesQuery = (tool: Tool, query: string) => @@ -131,7 +138,7 @@ const matchesQuery = (tool: Tool, query: string) => export function ToolControls({ tools, excludedTools = [], - selectedName, + selectedKey, searchText = "", listChanged, onRefreshList, @@ -146,14 +153,17 @@ export function ToolControls({ // Stamp each row's source position before filtering, so the key survives the // list narrowing (#1957). const filteredTools = tools - .map((tool, sourceIndex) => ({ tool, key: rowKey(tool.name, sourceIndex) })) + .map((tool, sourceIndex) => ({ + tool, + key: toolRowKey(tool.name, sourceIndex), + })) .filter(({ tool }) => !searchText || matchesQuery(tool, query)); // Excluded tools are searchable too, matching name AND title like the main // list above, so a filtered view stays consistent. const filteredExcluded = excludedTools .map((excluded, sourceIndex) => ({ ...excluded, - key: rowKey(excluded.tool.name, sourceIndex), + key: excludedRowKey(excluded.tool.name, sourceIndex), })) .filter(({ tool }) => !searchText || matchesQuery(tool, query)); @@ -183,9 +193,9 @@ export function ToolControls({ <ToolListItem key={key} tool={tool} - selected={tool.name === selectedName} + selected={key === selectedKey} onClick={() => { - if (tool.name !== selectedName) onSelectTool(tool.name); + if (key !== selectedKey) onSelectTool(key); }} /> ))} diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index 24cf06c2..da3ed278 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -82,6 +82,46 @@ describe("ToolsScreen", () => { ).not.toBeInTheDocument(); }); + it("opens the second copy of a duplicated tool name and calls it by its protocol name", async () => { + // A `tools/list` may repeat a name with a different schema. Selection is + // keyed by row identity, so the later copy is reachable and the detail + // panel shows *its* schema — a name-based lookup always resolved the first + // (#2001). The wire identity stays the duplicated name. + const user = userEvent.setup(); + const onCallTool = vi.fn(); + const duplicated: Tool[] = [ + { + name: "get_weather", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + }, + }, + { + name: "get_weather", + inputSchema: { + type: "object", + properties: { zip: { type: "string", default: "94103" } }, + }, + }, + ]; + renderWithMantine( + <ControlledToolsScreen tools={duplicated} onCallTool={onCallTool} />, + ); + const rows = screen.getAllByText("get_weather"); + await user.click(rows[1] as HTMLElement); + // The second copy's own field renders — the first copy's does not. + expect(screen.getByLabelText(/zip/i)).toBeInTheDocument(); + expect(screen.queryByLabelText(/city/i)).toBeNull(); + + await user.click(screen.getByRole("button", { name: /Execute/ })); + expect(onCallTool).toHaveBeenCalledWith( + "get_weather", + { zip: "94103" }, + false, + ); + }); + it("filters the sidebar list as the search text changes", async () => { const user = userEvent.setup(); renderWithMantine(<ControlledToolsScreen />); @@ -111,7 +151,7 @@ describe("ToolsScreen", () => { renderWithMantine( <ToolsScreen {...baseProps} - ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }} + ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }} callState={{ status: "ok", result: { content: [{ type: "text", text: "ok" }] }, @@ -134,7 +174,7 @@ describe("ToolsScreen", () => { function Host() { const [ui, setUi] = useState<ToolsUiState>({ ...EMPTY_TOOLS_UI, - selectedToolName: "alpha", + selectedToolKey: "0:alpha", }); const [callState, setCallState] = useState<ToolsScreenProps["callState"]>( { @@ -264,7 +304,7 @@ describe("ToolsScreen", () => { renderWithMantine( <ToolsScreen {...baseProps} - ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }} + ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }} callState={{ status: "pending" }} />, ); @@ -277,7 +317,7 @@ describe("ToolsScreen", () => { renderWithMantine( <ToolsScreen {...baseProps} - ui={{ ...EMPTY_TOOLS_UI, selectedToolName: "alpha" }} + ui={{ ...EMPTY_TOOLS_UI, selectedToolKey: "0:alpha" }} onCancelCall={onCancelCall} callState={{ status: "pending" }} />, diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx index a8afeb76..811e052e 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx @@ -16,6 +16,7 @@ import { ToolResultPanel } from "../../groups/ToolResultPanel/ToolResultPanel"; import { ToolCallErrorPanel } from "../../groups/ToolResultPanel/ToolCallErrorPanel"; import { resultHasResourceLinks } from "../../groups/ToolResultPanel/toolResultUtils"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; +import { findToolByRowKey } from "../../../utils/toolUtils"; export interface ToolCallState { status: "idle" | "pending" | "ok" | "error"; @@ -35,7 +36,14 @@ export interface ToolCallState { // as one object so they persist across tab navigation within a live session; // the screen unmounts on tab switch, so local state would be lost (#1414/#1417). export interface ToolsUiState { - selectedToolName?: string; + /** + * The selected row's `toolRowKey` — its position in the list plus its name, + * not the name alone. A `tools/list` may repeat a name, and identifying the + * selection by name highlighted every copy while the detail panel always + * resolved the first, so the later copies could not be inspected (#2001). + * The protocol name a `tools/call` sends still comes from the resolved tool. + */ + selectedToolKey?: string; formValues: Record<string, unknown>; search: string; // Screen-level "Run as task" toggle, shared across tools (not per-tool): @@ -168,27 +176,25 @@ export function ToolsScreen({ onClearResult, onReadResource, }: ToolsScreenProps) { - const { selectedToolName, formValues, search } = ui; - const selectedTool = selectedToolName - ? tools.find((t) => t.name === selectedToolName) - : undefined; + const { selectedToolKey, formValues, search } = ui; + const selectedTool = findToolByRowKey(tools, selectedToolKey); const isExecuting = callState?.status === "pending"; - const handleSelectTool = (name: string) => { + const handleSelectTool = (key: string) => { // Seed the form with the tool's schema defaults so default-only fields the // user never edits are still sent on execute (the form shows defaults via // resolveValue, but onChange only writes edited fields). - const tool = tools.find((t) => t.name === name); - // `name` always comes from the rendered tools list (ToolControls only emits - // names it was given), so the lookup never misses; the empty-object fallback - // is an unreachable defensive default. + const tool = findToolByRowKey(tools, key); + // `key` always comes from the rendered tools list (ToolControls only emits + // keys it computed from the tools it was given), so the lookup never misses; + // the empty-object fallback is an unreachable defensive default. let nextFormValues: Record<string, unknown> = {}; - /* v8 ignore next -- unreachable: onSelectTool always names a tool in the list */ + /* v8 ignore next -- unreachable: onSelectTool always keys a tool in the list */ if (tool) nextFormValues = collectSchemaDefaults( toFormSchema(tool.inputSchema) ?? {}, ); - onUiChange({ ...ui, selectedToolName: name, formValues: nextFormValues }); + onUiChange({ ...ui, selectedToolKey: key, formValues: nextFormValues }); }; return ( @@ -199,7 +205,7 @@ export function ToolsScreen({ tools={tools} excludedTools={excludedTools} malformedListItems={malformedListItems} - selectedName={selectedToolName} + selectedKey={selectedToolKey} searchText={search} listChanged={listChanged} onRefreshList={onRefreshList} diff --git a/clients/web/src/components/screens/screenUiState.ts b/clients/web/src/components/screens/screenUiState.ts index fab0df21..1da027cf 100644 --- a/clients/web/src/components/screens/screenUiState.ts +++ b/clients/web/src/components/screens/screenUiState.ts @@ -16,7 +16,7 @@ import { ALL_LEVELS_VISIBLE } from "./LoggingScreen/logLevels"; import { ALL_CATEGORIES_VISIBLE } from "./NetworkScreen/fetchCategories"; export const EMPTY_TOOLS_UI: ToolsUiState = { - selectedToolName: undefined, + selectedToolKey: undefined, formValues: {}, search: "", runAsTask: false, diff --git a/clients/web/src/lib/oauthResume.test.ts b/clients/web/src/lib/oauthResume.test.ts index c1fef0e9..34ccdad5 100644 --- a/clients/web/src/lib/oauthResume.test.ts +++ b/clients/web/src/lib/oauthResume.test.ts @@ -80,7 +80,7 @@ describe("oauthResume", () => { tabUi: { Tools: { ...EMPTY_TOOLS_UI, - selectedToolName: "echo", + selectedToolKey: "0:echo", formValues: { message: "hi" }, }, }, @@ -96,7 +96,7 @@ describe("oauthResume", () => { it("builds and restores tab ui snapshots", () => { const toolsUi = { ...EMPTY_TOOLS_UI, - selectedToolName: "get_temp", + selectedToolKey: "1:get_temp", formValues: { city: "NYC" }, }; const tabUi = buildTabUiSnapshot({ @@ -186,7 +186,7 @@ describe("oauthResume", () => { it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => { const toolsUi = { ...EMPTY_TOOLS_UI, - selectedToolName: "get_temp", + selectedToolKey: "1:get_temp", formValues: { city: "NYC" }, }; const snapshot: OAuthResumeSnapshot = { diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index c54d8a2e..80cdf95d 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import type { Tool } from "@modelcontextprotocol/client"; -import { hasInputFields, resolveDisplayLabel } from "./toolUtils"; +import { + findToolByRowKey, + hasInputFields, + resolveDisplayLabel, + toolRowKey, +} from "./toolUtils"; describe("resolveDisplayLabel", () => { it("returns the title when provided", () => { @@ -47,3 +52,37 @@ describe("hasInputFields", () => { ).toBe(true); }); }); + +describe("toolRowKey / findToolByRowKey", () => { + const tool = (name: string): Tool => ({ + name, + inputSchema: { type: "object" }, + }); + // A `tools/list` may legitimately repeat a name (#1957/#2001). + const tools: Tool[] = [ + tool("get_weather"), + tool("echo"), + tool("get_weather"), + ]; + + it("distinguishes two tools that share a name", () => { + expect(toolRowKey("get_weather", 0)).not.toBe(toolRowKey("get_weather", 2)); + }); + + it("resolves each duplicate to its own entry", () => { + expect(findToolByRowKey(tools, "0:get_weather")).toBe(tools[0]); + // The second copy — the one a name-based lookup could never reach. + expect(findToolByRowKey(tools, "2:get_weather")).toBe(tools[2]); + }); + + it("returns undefined for no key", () => { + expect(findToolByRowKey(tools, undefined)).toBeUndefined(); + }); + + it("returns undefined for a key naming no current row", () => { + // A stale selection after the list changed underneath it. + expect(findToolByRowKey(tools, "9:gone")).toBeUndefined(); + // Right name, wrong position: still not a match. + expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined(); + }); +}); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 247e31b4..4231a83e 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -19,3 +19,35 @@ export function resolveDisplayLabel(name: string, title?: string): string { export function hasInputFields(tool: Tool): boolean { return Object.keys(tool.inputSchema.properties ?? {}).length > 0; } + +/** + * A tool's stable per-row identity in the Tools sidebar. + * + * A server may return the same tool name more than once, so the name alone + * identifies neither a React child nor a selection: colliding keys let a + * filtered-out row survive reconciliation (#1957), and a name-keyed selection + * highlights both copies at once while the detail panel can only ever resolve + * the first (#2001). The tool's position in the *unfiltered* list disambiguates + * duplicates and stays stable while the search narrows, since it is captured + * before filtering. + * + * This is a UI identity only — the wire identity is still `tool.name`, which is + * what a `tools/call` must send. + */ +export function toolRowKey(name: string, sourceIndex: number): string { + return `${sourceIndex}:${name}`; +} + +/** + * Resolves the tool a {@link toolRowKey} refers to, or `undefined` when the key + * names no row in the current list (e.g. the list changed under a stale + * selection). Compares computed keys rather than parsing the key, so the two + * stay in lockstep if the format ever changes. + */ +export function findToolByRowKey( + tools: Tool[], + key: string | undefined, +): Tool | undefined { + if (key === undefined) return undefined; + return tools.find((tool, index) => toolRowKey(tool.name, index) === key); +} From c6fb627142481420b68ced98b1c589cf80185f5e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:03:10 -0400 Subject: [PATCH 206/941] fix: count a pct-triplet as one character under every operator Addresses Copilot's round-11 review on #2035, which corrects my own round-9 fix rather than the original code. That fix grouped pct-triplets only under `+` and `#`, on the reasoning that every other operator escapes a `%` anyway, so `%61` is three characters rather than one. Wrong, in a way the narrower rule made visible one operator over -- measured on 2e06b369: x/{v:1} v = "%61%62" => "x/%25" truncated to a `%` that was never a character of the value RFC 6570 2.4.1 counts characters and says a pct-encoded triplet counts as a single one, without reference to the operator. Grouping per-operator also made the same input mean two different things: `%61` indivisible under `+`, three loose characters under `{v}`. What the operator decides is how a retained unit is *encoded* afterwards -- a simple expansion still escapes the kept triplet's `%`, so `%61` goes out as `%2561` -- not what counts as one character. `prefixUnits` no longer takes an operator, and the test that codified the old split now asserts the uniform rule across simple, named, and reserved expansion. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 24 +++++++++++--- core/mcp/uriTemplate.ts | 31 +++++++++++-------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index b249d638..c616d090 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -714,11 +714,25 @@ describe("prefix modifier truncation", () => { ); }); - it("counts a triplet as three characters where the encoder escapes it", () => { - // Under a non-reserved operator `%` is escaped anyway (`%61` -> `%2561`), - // so the three characters really are three and grouping them would - // mis-count the prefix. - expect(expandUriTemplate("x/{v:3}", { v: "%61%62" })).toBe("x/%2561"); + it.each([ + // The counting rule is operator-INDEPENDENT: a triplet is one character + // wherever it appears. An earlier revision grouped only under `+`/`#`, + // which made the same input mean two things and truncated `{v:1}` to a `%` + // that was never a character of the value (it emitted "%25"). + ["x/{v:1}", "x/%2561"], + ["x/{v:2}", "x/%2561%2562"], + ["x/{v:3}", "x/%2561%2562"], + ])( + "counts a triplet as one character under a simple expansion (%s)", + (template, expected) => { + // What the operator decides is the *encoding* afterwards -- a simple + // expansion still escapes the retained triplet's `%`, so `%61` -> `%2561`. + expect(expandUriTemplate(template, { v: "%61%62" })).toBe(expected); + }, + ); + + it("counts a triplet as one character under a named operator too", () => { + expect(expandUriTemplate("x{?v:1}", { v: "%61%62" })).toBe("x?v=%2561"); }); it("truncates by code point, not UTF-16 code unit", () => { diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index cbcaffc4..28ce4c15 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -506,23 +506,28 @@ function encodeValue(value: string, operator: string): string { /** * Splits a value into the units a prefix modifier counts. * - * RFC 6570 §2.4.1 counts *characters*, and is explicit that "a pct-encoded - * triplet counts as a single character" — so `{+v:5}` over `%61%62%63%64%65%66` - * must stop after five whole triplets. Splitting on code points alone cut the - * sixth in half, and the trailing `%6` was then no longer a triplet, so - * `encodeAllowReserved` correctly encoded its `%`: the value went out as - * `%61%256`, a byte the server never asked for. + * RFC 6570 §2.4.1 counts *characters*, and is explicit that a pct-encoded + * triplet counts as a single one — so `{+v:5}` over `%61%62%63%64%65%66` stops + * after five whole triplets. Splitting on code points cut the sixth in half, + * and the orphaned `%6` was no longer a triplet, so `encodeAllowReserved` + * correctly escaped its `%`: the value went out as `%61%256`, a byte the caller + * never asked for. * - * Triplets are units only under `+` and `#`. Everywhere else the encoder - * escapes a `%` anyway (`%61` becomes `%2561`), so the three characters are - * three characters and grouping them would mis-count the prefix. + * The rule is **operator-independent**, which is a correction of the narrower + * version this shipped first. Grouping only under `+`/`#` made the same input + * mean two different things — `%61` an indivisible character under `+`, three + * loose characters under a simple expression — and produced the matching + * defect one operator over: `{v:1}` over `%61%62` kept just the `%` and emitted + * `%25`, a truncation to a character that was never in the value. What the + * operator decides is how a unit is *encoded* afterwards (a simple expansion + * still escapes the `%`, so the retained `%61` goes out as `%2561`), not what + * counts as one. * * The `u` flag makes the alternation match by code point, so an astral * character is one unit rather than a surrogate pair that truncation could - * split — the reason `Array.from` was used here in the first place. + * split. */ -function prefixUnits(value: string, operator: string): string[] { - if (!allowsReserved(operator)) return Array.from(value); +function prefixUnits(value: string): string[] { return value.match(/%[0-9A-Fa-f]{2}|[\s\S]/gu) ?? []; } @@ -537,7 +542,7 @@ function renderValue(value: string, spec: VarSpec, operator: string): string { const truncated = spec.maxLength === undefined ? value - : prefixUnits(value, operator).slice(0, spec.maxLength).join(""); + : prefixUnits(value).slice(0, spec.maxLength).join(""); return encodeValue(truncated, operator); } From c93552139cff38cb668270502b0d72a4a65c5222 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:18:40 -0400 Subject: [PATCH 207/941] fix(scripts): keep the resolver local, complete the cmd.exe quoting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review round 3, all three real: - `require.resolve.paths()` also appends Node's GLOBAL FOLDERS ($HOME/.node_modules, $HOME/.node_libraries, $PREFIX/lib/node) and any NODE_PATH entries, so round 2's lookup could satisfy a missing repo install from a GLOBALLY installed typescript/vite — measuring the programs with the wrong compiler instead of producing the actionable "run npm install" this helper promises. Filter to the node_modules directories on fromDir's own ancestor chain. - The quoting predicate missed characters cmd.exe still parses, notably parentheses (`C:\Temp(1)`), and quoting cannot suppress `%VAR%` at all — expansion happens before quotes are processed. Extracted to lib/win-shell-args.mjs with a broad predicate and a hard error on `%` rather than a silently different path. `platform` is a parameter so the Windows behavior is exercised on the POSIX machines that run the suite. - AGENTS.md claimed pack-and-verify spawns via `process.execPath`; it does not, and cannot — its children are `npm` and the installed `.bin` shim. State the exception and why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01387r3MotGk52hrkfjzHFsN Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 13 +++--- scripts/lib/resolve-node-bin.mjs | 33 ++++++++++++++- scripts/lib/resolve-node-bin.test.mjs | 49 +++++++++++++++++++++- scripts/lib/win-shell-args.mjs | 58 +++++++++++++++++++++++++++ scripts/lib/win-shell-args.test.mjs | 54 +++++++++++++++++++++++++ scripts/pack-and-verify.mjs | 21 ++++------ 6 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 scripts/lib/win-shell-args.mjs create mode 100644 scripts/lib/win-shell-args.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 4d901292..74b403d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,11 +129,14 @@ v2/main/ │ # (tsc-program.mjs is the `tsc --listFilesOnly` │ # measurement both coverage guards read a program │ # through — #1965; resolve-node-bin.mjs resolves a -│ # package's bin through its own package.json so the -│ # verify/smoke/pack scripts spawn it with -│ # `process.execPath` instead of the `npx`/`npm` -│ # `.cmd` shims Node refuses to spawn shell-free on -│ # Windows — #1939). Prettier-gated via +│ # package's bin through its own package.json, so the +│ # verify/smoke scripts spawn it with `process.execPath` +│ # rather than the `npx` `.cmd` shim Node refuses to +│ # spawn shell-free on Windows. pack-and-verify.mjs is +│ # the exception — its children are `npm` itself and the +│ # installed `.bin` shim, neither resolvable that way, so +│ # it keeps a Windows shell and quotes its generated +│ # paths via win-shell-args.mjs — #1939). Prettier-gated via │ # `format:check:scripts`; its own pure parsers are │ # unit-tested by `npm run test:scripts` (node --test). ├── specification/ # Build specification diff --git a/scripts/lib/resolve-node-bin.mjs b/scripts/lib/resolve-node-bin.mjs index 33d223ae..8caf9f04 100644 --- a/scripts/lib/resolve-node-bin.mjs +++ b/scripts/lib/resolve-node-bin.mjs @@ -69,17 +69,48 @@ export function resolveNodeBin(pkg, binName, fromDir) { * installed and perfectly spawnable — a false "not installed" that would send * someone to `npm install` for a package already on disk. `resolve.paths()` * gives the search dirs without consulting `exports` at all. + * + * Its list is then filtered to the `node_modules` directories on `fromDir`'s + * own ancestor chain, because `resolve.paths()` also appends Node's GLOBAL + * FOLDERS (`$HOME/.node_modules`, `$HOME/.node_libraries`, `$PREFIX/lib/node`) + * and any `NODE_PATH` entries. Accepting those would quietly break the + * guarantee this module exists to keep — that the spawned tsc/vite is the + * REPO-PINNED one, exactly as `npx --no-install` promised. A missing repo + * install would then find a globally installed TypeScript and measure the + * programs with the wrong compiler, instead of failing with the actionable + * "run npm install" this helper is supposed to produce. */ function resolveManifest(pkg, fromDir) { const require = createRequire(path.join(fromDir, "package.json")); // `resolve.paths` returns null only for builtins, which have no bin to find. - for (const dir of require.resolve.paths(pkg) ?? []) { + for (const dir of localSearchDirs( + require.resolve.paths(pkg) ?? [], + fromDir, + )) { const candidate = path.join(dir, ...pkg.split("/"), "package.json"); if (existsSync(candidate)) return candidate; } throw new Error(`cannot find ${pkg} from ${fromDir}`); } +/** + * The subset of `resolve.paths()` that is a `node_modules` directory sitting + * directly under `fromDir` or one of its ancestors — i.e. the local walk, with + * Node's global folders and `NODE_PATH` dropped. Exported for its own test. + */ +export function localSearchDirs(dirs, fromDir) { + const ancestors = new Set(); + for (let dir = path.resolve(fromDir); ; dir = path.dirname(dir)) { + ancestors.add(dir); + if (dir === path.dirname(dir)) break; + } + return dirs.filter( + (dir) => + path.basename(dir) === "node_modules" && + ancestors.has(path.dirname(path.resolve(dir))), + ); +} + /** `@scope/name` → `name`; an unscoped name is returned unchanged. */ function unscopedName(name) { return name.startsWith("@") ? name.slice(name.indexOf("/") + 1) : name; diff --git a/scripts/lib/resolve-node-bin.test.mjs b/scripts/lib/resolve-node-bin.test.mjs index e62a72ed..febcc749 100644 --- a/scripts/lib/resolve-node-bin.test.mjs +++ b/scripts/lib/resolve-node-bin.test.mjs @@ -17,7 +17,7 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { resolveNodeBin } from "./resolve-node-bin.mjs"; +import { localSearchDirs, resolveNodeBin } from "./resolve-node-bin.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -148,6 +148,53 @@ test("resolves a package whose `exports` hides ./package.json", () => { } }); +test("localSearchDirs keeps only node_modules on fromDir's ancestor chain", () => { + // `require.resolve.paths()` appends Node's GLOBAL FOLDERS and any NODE_PATH + // entries. Honouring those would break the guarantee this module exists for + // — that the spawned tsc/vite is the REPO-PINNED one — by letting a globally + // installed TypeScript stand in for a missing repo install, measuring the + // programs with the wrong compiler instead of failing actionably. + const from = path.join(path.sep, "repo", "clients", "web"); + const kept = [ + path.join(from, "node_modules"), + path.join(path.sep, "repo", "clients", "node_modules"), + path.join(path.sep, "repo", "node_modules"), + path.join(path.sep, "node_modules"), + ]; + const dropped = [ + path.join(path.sep, "home", "dev", ".node_modules"), + path.join(path.sep, "home", "dev", ".node_libraries"), + path.join(path.sep, "usr", "local", "lib", "node"), + // A NODE_PATH entry off the chain — a real node_modules, just not ours. + path.join(path.sep, "opt", "shared", "node_modules"), + ]; + assert.deepEqual(localSearchDirs([...kept, ...dropped], from), kept); +}); + +test("a package visible only outside the ancestor chain is not resolved", () => { + // The end-to-end form of the above: `ghostpkg` exists on disk, but under a + // sibling tree rather than an ancestor of `fromDir`. + const outside = fixtureDir( + { name: "elsewherepkg", bin: { elsewhere: "bin/e.js" } }, + { createBinFile: true }, + ); + const consumer = mkdtempSync(path.join(tmpdir(), "resolve-node-bin-from-")); + try { + writeFileSync( + path.join(consumer, "package.json"), + JSON.stringify({ name: "consumer" }), + "utf8", + ); + assert.throws( + () => resolveNodeBin("elsewherepkg", "elsewhere", consumer), + /cannot find elsewherepkg/, + ); + } finally { + rmSync(outside, { recursive: true, force: true }); + rmSync(consumer, { recursive: true, force: true }); + } +}); + test("accepts a scoped package's string-form bin under its unscoped name", () => { const dir = fixtureDir( { name: "@scope/tool", bin: "bin/tool.js" }, diff --git a/scripts/lib/win-shell-args.mjs b/scripts/lib/win-shell-args.mjs new file mode 100644 index 00000000..2e653bdf --- /dev/null +++ b/scripts/lib/win-shell-args.mjs @@ -0,0 +1,58 @@ +// Quoting for the few spawns that still need a Windows shell (#1939). +// +// Most call sites avoid the shell entirely — `resolve-node-bin.mjs` finds a +// package's JS entry and runs it with `process.execPath`. That is not available +// for `npm`/`npx` themselves: they are not resolvable from `node_modules`, and +// locating npm's own CLI relative to `process.execPath` differs across system, +// nvm, and volta installs — trading a quoting bug for a resolution bug. So the +// `npm` calls keep `shell: true`, which on Windows is the ONLY way to start the +// `.cmd` shim at all (Node has refused shell-free `.cmd`/`.bat` spawns since the +// CVE-2024-27980 hardening). +// +// With a shell, Node hands `cmd.exe` one space-joined string rather than an argv +// array, so any argument holding a space or a `cmd.exe` metacharacter is +// re-parsed as syntax. That is not hypothetical for these callers: every path +// they pass is generated under `tmpdir()`, which on Windows sits beneath the +// user profile — `C:\Users\First Last\AppData\Local\Temp` splits in two, and a +// profile name like `C:\Temp(1)` is read as grouping syntax. + +/** + * `cmd.exe` characters that make an argument parse as syntax rather than text. + * Deliberately broad — parentheses group, `^` escapes, `!` expands under + * delayed expansion, and a bare space splits. Anything not plainly inert is + * quoted; over-quoting is free, since `cmd.exe` and the CRT argument parser + * behind it both strip the quotes back off before the child sees them. + */ +const NEEDS_QUOTING = /[\s&|<>^()!"'`,;=[\]]/; + +/** + * A single argument, safe to pass through `cmd.exe`. + * + * Throws on `%`, which quoting genuinely cannot handle: `cmd.exe` expands + * `%NAME%` *before* it processes quotes, so `"%TEMP%"` is rewritten inside the + * quotes and the child receives a different path than we passed. There is no + * escape for it outside a batch file. A `%` in one of these generated temp + * paths is far-fetched, but silently running against the wrong directory is the + * exact class of failure this work exists to remove — so say so instead. + */ +export function quoteWinArg(arg) { + if (arg.includes("%")) { + throw new Error( + `cannot safely pass an argument containing "%" through cmd.exe (it is ` + + `expanded before quotes are processed): ${arg}`, + ); + } + if (!NEEDS_QUOTING.test(arg)) return arg; + // A literal `"` inside a quoted string is doubled, per the CRT parser. + return `"${arg.replace(/"/g, '""')}"`; +} + +/** + * `args`, quoted for `cmd.exe` when spawning with `shell: true` on Windows and + * returned untouched everywhere else — POSIX shells are not in play here, since + * these call sites only set `shell` on win32. + */ +export function winShellArgs(args, platform = process.platform) { + if (platform !== "win32") return args; + return args.map(quoteWinArg); +} diff --git a/scripts/lib/win-shell-args.test.mjs b/scripts/lib/win-shell-args.test.mjs new file mode 100644 index 00000000..7a1a40fa --- /dev/null +++ b/scripts/lib/win-shell-args.test.mjs @@ -0,0 +1,54 @@ +// Tests for `win-shell-args.mjs` (#1939). The platform is a parameter rather +// than a read of `process.platform`, so the Windows behaviour is exercised on +// the Linux/macOS machines that actually run this suite — otherwise every +// assertion below would be skipped exactly where the bug lives. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { quoteWinArg, winShellArgs } from "./win-shell-args.mjs"; + +test("passes args through untouched off Windows", () => { + const args = ["pack", "--pack-destination", "/tmp/dir with spaces"]; + assert.deepEqual(winShellArgs(args, "linux"), args); + assert.deepEqual(winShellArgs(args, "darwin"), args); +}); + +test("quotes a path with a space (the user-profile tmpdir case)", () => { + assert.equal( + quoteWinArg("C:\\Users\\First Last\\AppData\\Local\\Temp"), + '"C:\\Users\\First Last\\AppData\\Local\\Temp"', + ); +}); + +test("quotes cmd.exe metacharacters, parentheses included", () => { + // Parentheses group in cmd.exe, so `C:\Temp(1)` is syntax, not a path — the + // case a space-only predicate misses. + for (const meta of ["(", ")", "&", "|", "<", ">", "^", "!", ";", ",", "="]) { + const arg = `C:\\Temp${meta}1`; + assert.equal(quoteWinArg(arg), `"${arg}"`, `unquoted: ${arg}`); + } +}); + +test("leaves an inert argument unquoted", () => { + for (const arg of ["pack", "--json", "C:\\Users\\dev\\AppData\\Temp"]) { + assert.equal(quoteWinArg(arg), arg); + } +}); + +test("doubles an embedded quote", () => { + assert.equal(quoteWinArg('a"b c'), '"a""b c"'); +}); + +test("throws on `%` rather than silently mis-expanding it", () => { + // cmd.exe expands %NAME% before it processes quotes, so quoting cannot save + // this one — the child would receive a different path than we passed. + assert.throws(() => quoteWinArg("C:\\%TEMP%\\pack"), /cannot safely pass/); + assert.throws(() => winShellArgs(["ok", "%X%"], "win32"), /cannot safely/); +}); + +test("winShellArgs maps every element on win32", () => { + assert.deepEqual( + winShellArgs(["pack", "--pack-destination", "C:\\a b\\c"], "win32"), + ["pack", "--pack-destination", '"C:\\a b\\c"'], + ); +}); diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 7a0deedf..1a1f8f2a 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -44,6 +44,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { hasExited, removeSafe, stopChild } from "./lib/child-cleanup.mjs"; +import { winShellArgs } from "./lib/win-shell-args.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); const testServer = join( @@ -98,21 +99,13 @@ function step(message) { console.log(`\npack:verify — ${message}`); } -// Windows needs `shell: true` to start the `npm`/`npx` `.cmd` shims at all -// (#1939), and with a shell Node hands the argv to `cmd.exe` as ONE -// space-joined string — so an argument holding a path with a space is split -// into two. That is not hypothetical here: every generated path in this file -// lives under `tmpdir()`, which on Windows sits beneath the user profile and -// so contains a space whenever the account name does. Quote each argument -// that needs it; `cmd.exe` (and the CRT parser behind it) strips the quotes -// back off, so the child sees exactly the argument we passed. +// This script keeps `shell: true` on Windows because its children are `npm` and +// `npx` — the two commands `resolve-node-bin.mjs` deliberately cannot replace +// (see the note at the top of `lib/win-shell-args.mjs`) — plus the installed +// `.bin` shim. The shell then re-parses the argv, so every generated path we +// pass has to be quoted for `cmd.exe`. const WIN_SHELL = process.platform === "win32"; -function shellArgs(args) { - if (!WIN_SHELL) return args; - return args.map((arg) => - /[\s&|<>^"]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg, - ); -} +const shellArgs = (args) => winShellArgs(args); /** Run a command to completion, inheriting stdio. Returns the exit status. */ function runInherit(command, args, cwd = repoRoot) { From 63b37e05ec733d542c029a03bdcd034fb05c563a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:19:22 -0400 Subject: [PATCH 208/941] fix(web): drop a pre-#2001 selectedToolName on OAuth resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A resume snapshot written before this change carries `selectedToolName` where `ToolsUiState` now expects `selectedToolKey`. The blind cast in `restoreTabUiFromSnapshot` carried it through as a stray field that would be re-serialized on the next redirect. Route the Tools entry through a `normalizeToolsUi` that drops it explicitly instead. The name is deliberately not migrated to a row key: a snapshot lives for one redirect (so this only arises when the app is upgraded mid-flight), and the tools list is fetched after reconnect, long after restore — there is nothing to resolve the name against. The selection is lost; search, form values, and the run-as-task toggle restore intact. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/lib/oauthResume.test.ts | 34 +++++++++++++++++++++++++ clients/web/src/lib/oauthResume.ts | 21 ++++++++++++--- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/clients/web/src/lib/oauthResume.test.ts b/clients/web/src/lib/oauthResume.test.ts index 34ccdad5..bd73611d 100644 --- a/clients/web/src/lib/oauthResume.test.ts +++ b/clients/web/src/lib/oauthResume.test.ts @@ -123,6 +123,40 @@ describe("oauthResume", () => { expect(setToolsUi).toHaveBeenCalledWith(toolsUi); }); + it("drops a pre-#2001 selectedToolName from a legacy snapshot, keeping the rest", () => { + // A redirect begun on a build that keyed tool selection by name, restored + // on one that keys it by row identity (the app upgraded mid-redirect). + // The name can't be mapped to a row key here — the tools list is fetched + // after reconnect — so the selection is dropped, not carried through as a + // stray field, and everything else survives. + const setToolsUi = vi.fn(); + restoreTabUiFromSnapshot( + { + Tools: { + formValues: { city: "NYC" }, + search: "get", + runAsTask: false, + selectedToolName: "get_temp", + }, + }, + { + setToolsUi, + setPromptsUi: vi.fn(), + setResourcesUi: vi.fn(), + setAppsUi: vi.fn(), + setTasksUi: vi.fn(), + setLogsUi: vi.fn(), + setProtocolUi: vi.fn(), + setNetworkUi: vi.fn(), + }, + ); + expect(setToolsUi).toHaveBeenCalledWith({ + formValues: { city: "NYC" }, + search: "get", + runAsTask: false, + }); + }); + it("falls back to legacy pending server key", () => { storage.set(OAUTH_PENDING_SERVER_KEY, "legacy-srv"); const snapshot = readOAuthResumeSnapshot(); diff --git a/clients/web/src/lib/oauthResume.ts b/clients/web/src/lib/oauthResume.ts index 30a05837..4684ed71 100644 --- a/clients/web/src/lib/oauthResume.ts +++ b/clients/web/src/lib/oauthResume.ts @@ -96,6 +96,23 @@ export function buildTabUiSnapshot( }; } +/** + * A snapshot written before #2001 carries `selectedToolName` (a tool's name) + * where {@link ToolsUiState} now expects `selectedToolKey` (its `index:name` + * row identity). A snapshot only lives for the length of one OAuth redirect, + * so this matters exactly when the app is upgraded mid-redirect — and the name + * cannot be mapped to a row key here, since the tools list is fetched after + * reconnect, long after restore. So the stale selection is dropped rather than + * carried as a stray field that would be re-serialized on the next redirect; + * the rest of the tab state (search text, form values) is restored intact and + * the screen opens on its "select a tool" placeholder. + */ +function normalizeToolsUi(value: unknown): ToolsUiState { + const ui = { ...((value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI) }; + delete (ui as ToolsUiState & { selectedToolName?: string }).selectedToolName; + return ui; +} + export function restoreTabUiFromSnapshot( tabUi: Partial<Record<InspectorTabId, unknown>> | undefined, setters: TabUiSetters, @@ -110,9 +127,7 @@ export function restoreTabUiFromSnapshot( const value = tabUi[tabId]; switch (tabId) { case "Tools": - setters.setToolsUi( - (value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI, - ); + setters.setToolsUi(normalizeToolsUi(value)); break; case "Prompts": setters.setPromptsUi( From 6a345ae77c3c467b6525c62554b3567f0e614337 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:22:09 -0400 Subject: [PATCH 209/941] fix: keep the URI preview from throwing or promising an unsendable URI Addresses Copilot's round-12 review on #2035. Both halves are regressions from my own round-10 rework, which replaced "expand a rewritten template through the lenient expandUriTemplate" with a part-by-part assembly and so lost two properties that implementation had for free. Reproduced on c6fb6271: previewUriTemplate("x://{v}", { v: "\ud800" }) => throws URIError previewUriTemplate("x://{a,}", { a: "1" }) => "x://1" The first is the worse one: the preview runs during render, so an escaping throw unmounts the whole panel rather than disabling its button -- and an unpaired surrogate is real input, since `encodeURIComponent` raises URIError on one and a paste can deliver it. The panel already reports that case properly through `tryExpandUriTemplate`; the display path now degrades to the raw template instead of taking the panel down with it. The second promised a URI the user could never send: `{a,}` is an invalid template whose submission is refused, but `a` survives into the expression's varspecs, so filling it previewed `x://1`. An invalid expression is now left standing as written, which is what the rest of the panel already says about it. Tests for both, plus one asserting the valid expressions around an invalid one still preview. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/utils/uriTemplate.test.ts | 27 +++++++++++++++ clients/web/src/utils/uriTemplate.ts | 40 ++++++++++++++++------- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index f25c9e73..72e5f824 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -99,3 +99,30 @@ describe("previewUriTemplate - literals", () => { ); }); }); + +describe("previewUriTemplate - it must never throw or over-promise", () => { + // Both of these were free when the preview expanded a rewritten template + // through the lenient `expandUriTemplate`; assembling part by part has to + // reinstate them explicitly. + it("falls back to the raw template when a value cannot be encoded", () => { + // An unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent` + // raises URIError -- and this runs during render, where an escaping throw + // unmounts the panel rather than disabling its button. A paste delivers it. + expect(previewUriTemplate("x://{v}", { v: "\ud800" })).toBe("x://{v}"); + }); + + it("leaves an invalid expression standing rather than expanding it", () => { + // `{a,}` keeps `a` in its varspecs, so expanding it would preview a URI + // for a template whose submission is refused outright. + expect(previewUriTemplate("x://{a,}", { a: "1" })).toBe("x://{a,}"); + expect(previewUriTemplate("x://{id:abc}", { "id:abc": "1" })).toBe( + "x://{id:abc}", + ); + }); + + it("still previews the valid expressions around an invalid one", () => { + expect(previewUriTemplate("x://{a,}/{b}", { a: "1", b: "2" })).toBe( + "x://{a,}/2", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 78db1bc9..918f5907 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -50,6 +50,19 @@ export type { * Each half still comes from the shared expander -- `encodeLiteral` for literal * runs, `expandTemplateExpression` for filled ones -- so the preview cannot * promise a URI that submitting would not send. + * + * Two guards keep that promise, both of them things the previous + * expand-the-whole-template implementation got for free from the lenient + * `expandUriTemplate` it called: + * + * - **An invalid expression is left standing.** `{a,}` still parses `a` into + * its varspecs, so filling `a` would preview `x://1` for a template whose + * submission is refused outright -- a URI the user could never send. + * - **The whole thing is wrapped.** This runs during render, where a throw + * unmounts the panel instead of disabling its button, and encoding can throw + * for real input: `encodeURIComponent` raises `URIError` on an unpaired + * surrogate, which a paste can deliver. The panel already reports that case + * through `tryExpandUriTemplate`; here it degrades to the raw template. */ export function previewUriTemplate( uriTemplate: string, @@ -57,15 +70,20 @@ export function previewUriTemplate( ): string { const defined = definedValues(values); - return parseUriTemplate(uriTemplate) - .map((part) => { - if (part.kind === "literal") return encodeLiteral(part.text); - // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are - // valid RFC 6570 variable names, and a plain lookup would find - // `Object.prototype`'s member and treat a blank field as filled. - return part.names.some((name) => Object.hasOwn(defined, name)) - ? expandTemplateExpression(part, defined) - : part.source; - }) - .join(""); + try { + return parseUriTemplate(uriTemplate) + .map((part) => { + if (part.kind === "literal") return encodeLiteral(part.text); + if (part.invalid) return part.source; + // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are + // valid RFC 6570 variable names, and a plain lookup would find + // `Object.prototype`'s member and treat a blank field as filled. + return part.names.some((name) => Object.hasOwn(defined, name)) + ? expandTemplateExpression(part, defined) + : part.source; + }) + .join(""); + } catch { + return uriTemplate; + } } From 3b995f6658402bd1e18c821ccca4758f59fb5cb5 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:23:26 -0400 Subject: [PATCH 210/941] docs(spec): describe tool selection by row key, not name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `v2_ux_interfaces.md` and `v2_storage.md` still prescribed `selectedName` / `selectedToolName` and `tools.find(t => t.name === …)` — the exact shape that made a duplicated tool name uninspectable. Point them at `toolRowKey` / `findToolByRowKey` and note the split between UI identity (the row key, carried by `onSelectTool`) and wire identity (the protocol name, carried by `onCallTool`). Follow-up to #2001 review feedback. Signed-off-by: cliffhall <cliff@futurescale.com> --- specification/v2_storage.md | 4 +++- specification/v2_ux_interfaces.md | 9 +++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/specification/v2_storage.md b/specification/v2_storage.md index 33e8687a..3ff99e71 100644 --- a/specification/v2_storage.md +++ b/specification/v2_storage.md @@ -304,7 +304,9 @@ interface LogsDisplayActions { ```typescript interface ExecutionFormState { // Tools - selectedToolName: string | null; + // The selected row's `toolRowKey` (`index:name`), not the tool's name — a + // `tools/list` may repeat a name (#2001). + selectedToolKey: string | null; toolFormValues: Record<string, unknown>; lastToolResult: ToolResult | null; diff --git a/specification/v2_ux_interfaces.md b/specification/v2_ux_interfaces.md index f1e3a158..c97c03e8 100644 --- a/specification/v2_ux_interfaces.md +++ b/specification/v2_ux_interfaces.md @@ -646,7 +646,8 @@ will close out as part of that work. - **Purpose**: Searchable tool list sidebar with list-changed indicator. - **Current props**: `tools: ToolListItemProps[]`, `listChanged`, `onRefreshList`, `onSelectTool`. - **MCP schema touch points**: `Tool`, `ListToolsResult`, `ToolListChangedNotification`. -- **Target props**: `tools: Tool[]`, `selectedName?`, `listChanged`, `onRefreshList`, `onSelectTool`. +- **Target props**: `tools: Tool[]`, `selectedKey?`, `listChanged`, `onRefreshList`, `onSelectTool`. +- **Row identity**: a `tools/list` may repeat a name, so rows are keyed — for React reconciliation *and* for selection — by `toolRowKey(name, sourceIndex)` (`utils/toolUtils.ts`), not by `tool.name`. `selectedKey` and `onSelectTool` both carry that key (#1957/#2001). - **Callbacks → core hook**: `useManagedTools`. - **Internal refactors**: Consume schema `Tool[]` directly. @@ -716,14 +717,14 @@ themselves — every callback passed down must originate in a core hook. - **MCP schema touch points**: `Tool`, `ListToolsResult`, `CallToolRequest.params`, `CallToolResult`, `ToolListChangedNotification`. - **Target props**: - `tools: Tool[]` — raw MCP `Tool[]` from `ListToolsResult.tools`. - - `selectedToolName?: string` — selection is screen-local; detail derived from `tools`. + - `selectedToolKey?: string` — selection is screen-local; detail derived from `tools`. This is the row's `toolRowKey`, not its name: duplicate names would otherwise select every copy at once and resolve only the first (#2001). - `callState?: { status: 'idle' | 'pending' | 'ok' | 'error'; request?: CallToolRequest['params']; result?: CallToolResult; error?: string }`. - `listChanged: boolean` — driven by `notifications/tools/list_changed`. - - `onRefreshList: () => void`, `onSelectTool: (name: string) => void`, `onCallTool: (name: string, args: Record<string, unknown>) => void`. + - `onRefreshList: () => void`, `onSelectTool: (key: string) => void`, `onCallTool: (name: string, args: Record<string, unknown>) => void` — note `onSelectTool` carries the row key (UI identity) while `onCallTool` carries the protocol name (wire identity). - **Callbacks → core hook**: `useManagedTools` provides `tools`, `listChanged`, `refreshTools`, `callTool`, `lastCallResult`; backed by `useInspectorClient` for the underlying `client.request`. - **Internal refactors**: - Stop flattening `Tool` into `ToolListItemProps` at the screen boundary — pass `Tool` down and let `ToolListItem` project fields. - - Derive the selected tool from `tools.find(t => t.name === selectedToolName)` instead of receiving a pre-built `ToolDetailPanelProps`. + - Derive the selected tool from `findToolByRowKey(tools, selectedToolKey)` instead of receiving a pre-built `ToolDetailPanelProps`. - Replace the separate `result` prop with a unified `callState` so pending/error UI can be rendered by `ToolResultPanel`. ### PromptsScreen From 1c595e96ee9e7dbc2a29c021c23448b4947fe584 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:33:52 -0400 Subject: [PATCH 211/941] fix: dedupe a repeated name in a group, and name every unmet requirement Addresses Copilot's round-13 review on #2035. Two suppressed comments, both real, both in the required-group work from round 7 rather than in the recent preview rework. 1. `{a,a}` is ONE requirement named twice, but `requiredGroups` stored it as a two-name group, so everything downstream read it as "either of these will do". The TUI was left with a form it could not submit: its `length === 1` test marked `a` optional while ResourceTestModal's guard still refused a blank. The web panel offered the useless hint "Any one of: a, a". Names are now deduplicated within the expression -- expansion still emits both occurrences (`x://{a,a}` -> `x://1,1`), which RFC 6570 requires. 2. The per-field hint used `find`, so a variable in several shared groups advertised only the first. With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two and every visible hint then looks met while Read Resource stays disabled on the unmet `{a,c}` -- a requirement with nothing on screen pointing at it. Fields now hint every shared group they sit in. That alone is not enough, because no per-field hint can say WHICH group is still outstanding, so the form states it directly: "Still needed: a or c", built from the same `unmetRequiredGroups` the submit gate uses, so the message and the button cannot disagree. `hasRequiredValues` is no longer called here -- the panel derives both from the one list. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../tui/__tests__/uriTemplateToForm.test.ts | 12 +++++ .../ResourceTemplatePanel.test.tsx | 52 +++++++++++++++++++ .../ResourceTemplatePanel.tsx | 39 +++++++++++--- .../web/src/test/core/mcp/uriTemplate.test.ts | 18 +++++++ clients/web/src/utils/uriTemplate.ts | 1 + core/mcp/uriTemplate.ts | 7 ++- 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/clients/tui/__tests__/uriTemplateToForm.test.ts b/clients/tui/__tests__/uriTemplateToForm.test.ts index 9dc1a32c..c44b6b37 100644 --- a/clients/tui/__tests__/uriTemplateToForm.test.ts +++ b/clients/tui/__tests__/uriTemplateToForm.test.ts @@ -64,3 +64,15 @@ describe("uriTemplateToForm", () => { expect(form.sections[0]!.fields).toEqual([]); }); }); + +describe("a name repeated inside one expression", () => { + it("is required, not treated as a shared group", () => { + // `{a,a}` is one requirement named twice. Before core deduplicated the + // group, `requiredGroups` returned ["a","a"], so this form's + // `length === 1` test left the field optional while ResourceTestModal's + // submit guard still refused a blank -- an un-submittable form. + const [field] = uriTemplateToForm("x://{a,a}", "T").sections[0].fields; + expect(field.name).toBe("a"); + expect(field.required).toBe(true); + }); +}); diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index 87b2ba15..b7d6c934 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -266,6 +266,58 @@ describe("ResourceTemplatePanel", () => { expect(onReadResource).not.toHaveBeenCalled(); }); + it("names every unmet group, so a disabled button always has a reason", async () => { + // With `{a,b}{b,c}{a,c}`, filling `b` satisfies the first two groups. A + // per-field hint then looks satisfied while Read Resource stays disabled + // on the third -- so the outstanding requirement is stated form-level. + const user = userEvent.setup(); + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Shared", uriTemplate: "x://{a,b}{b,c}{a,c}" }} + onReadResource={vi.fn()} + />, + ); + expect(screen.getByText(/Still needed:/)).toHaveTextContent( + "Still needed: a or b; b or c; a or c", + ); + await user.type(screen.getByLabelText("b"), "1"); + expect(screen.getByText(/Still needed:/)).toHaveTextContent( + "Still needed: a or c", + ); + expect( + screen.getByRole("button", { name: "Read Resource" }), + ).toBeDisabled(); + await user.type(screen.getByLabelText("a"), "2"); + expect(screen.queryByText(/Still needed:/)).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Read Resource" }), + ).toBeEnabled(); + }); + + it("hints every shared group a variable sits in, not just the first", () => { + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Shared", uriTemplate: "x://{a,b}{a,c}" }} + onReadResource={vi.fn()} + />, + ); + expect( + screen.getByText("Any one of each: a, b; a, c"), + ).toBeInTheDocument(); + }); + + it("treats a name repeated in one expression as a plain requirement", () => { + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Twice", uriTemplate: "x://{a,a}" }} + onReadResource={vi.fn()} + />, + ); + // Not "Any one of: a, a" -- it is one field, required. + expect(screen.queryByText(/Any one of/)).not.toBeInTheDocument(); + expect(screen.getByText("Still needed: a")).toBeInTheDocument(); + }); + it("shows the malformed template unexpanded in the preview", () => { renderWithMantine( <ResourceTemplatePanel diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index 16db62e7..d1e7137d 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -16,11 +16,11 @@ import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge" import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { definedValues, - hasRequiredValues, previewUriTemplate, requiredGroups, templateVariables, tryExpandUriTemplate, + unmetRequiredGroups, } from "../../../utils/uriTemplate"; export interface ResourceTemplatePanelProps { @@ -77,6 +77,13 @@ const DescriptionText = Text.withProps({ // value that cannot be encoded -- so a screen-reader user would otherwise meet // a silently disabled action. An alert is an assertive live region, which is // what announces text that arrives after first render. +// What is still missing before Read Resource can fire. Dimmed rather than red: +// an incomplete form is the expected starting state, not an error. +const RequirementText = Text.withProps({ + size: "sm", + c: "dimmed", +}); + const ExpansionErrorText = Text.withProps({ size: "sm", c: "red", @@ -255,8 +262,11 @@ export function ResourceTemplatePanel({ // untouched field is indistinguishable from a deliberately empty one. That is // a fact about the form, so the form is what resolves it. const expansion = tryExpandUriTemplate(uriTemplate, definedValues(variables)); - const canSubmit = - expansion.error === undefined && hasRequiredValues(groups, variables); + // Named rather than merely counted, so a disabled Read Resource always has a + // reason on screen. Per-field hints cannot carry this: a variable shared + // across several groups looks satisfied once any one of them is met. + const unmet = unmetRequiredGroups(groups, variables); + const canSubmit = expansion.error === undefined && unmet.length === 0; function handleSubmit() { /* v8 ignore next -- unreachable: the button is disabled whenever the @@ -293,15 +303,23 @@ export function ResourceTemplatePanel({ const individuallyRequired = groups.some( (names) => names.length === 1 && names[0] === varName, ); - const sharedGroup = individuallyRequired - ? undefined - : groups.find( + // EVERY shared group this variable sits in, not the first. With + // `{a,b}{b,c}{a,c}`, showing only the first left `b` looking like it + // satisfied everything while Read Resource stayed disabled on the + // unmet `{a,c}` -- a hidden requirement is worse than a wordy hint. + // The form-level "Still needed" line below names what is actually + // outstanding, which is the part a per-field hint cannot express. + const sharedGroups = individuallyRequired + ? [] + : groups.filter( (names) => names.length > 1 && names.includes(varName), ); const description = !required ? "Optional" - : sharedGroup - ? `Any one of: ${sharedGroup.join(", ")}` + : sharedGroups.length > 0 + ? `Any one of${sharedGroups.length > 1 ? " each" : ""}: ${sharedGroups + .map((names) => names.join(", ")) + .join("; ")}` : undefined; return useAutocomplete ? ( <Autocomplete @@ -344,6 +362,11 @@ export function ResourceTemplatePanel({ {expansion.error !== undefined && ( <ExpansionErrorText>{expansion.error}</ExpansionErrorText> )} + {expansion.error === undefined && unmet.length > 0 && ( + <RequirementText> + Still needed: {unmet.map((names) => names.join(" or ")).join("; ")} + </RequirementText> + )} <FooterRow> <Button size="sm" disabled={!canSubmit} onClick={handleSubmit}> Read Resource diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index c616d090..a560eed0 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -352,6 +352,24 @@ describe("requiredGroups / hasRequiredValues", () => { }); }); +describe("a name repeated inside one expression", () => { + // `{a,a}` is one requirement named twice, not two names either of which + // would do. Undeduplicated it read as a shared group everywhere downstream: + // the TUI's `length === 1` test marked `a` optional while its submit guard + // still refused a blank, and the web panel offered "Any one of: a, a". + it("collapses to a single-name required group", () => { + expect(requiredGroups("x://{a,a}")).toEqual([["a"]]); + }); + + it("still expands both occurrences, which RFC 6570 requires", () => { + expect(expandUriTemplate("x://{a,a}", { a: "1" })).toBe("x://1,1"); + }); + + it("leaves genuinely distinct names alone", () => { + expect(requiredGroups("x://{a,b}")).toEqual([["a", "b"]]); + }); +}); + describe("expression independence", () => { it("does not rewrite a later query expression on the own-expansion path", () => { expect( diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 918f5907..81ecd3b3 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -26,6 +26,7 @@ export { requiredGroups, templateVariables, tryExpandUriTemplate, + unmetRequiredGroups, } from "@inspector/core/mcp/uriTemplate.js"; export type { TemplateExpansion, diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 28ce4c15..55f16543 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -317,7 +317,12 @@ export function requiredGroups(uriTemplate: string): string[][] { // time on a condition nothing can satisfy, and the "any one of" message // built from it would name no fields. if (part.names.length === 0) continue; - groups.push(part.names); + // Deduplicated within the expression: `{a,a}` is one requirement named + // twice, not two names either of which would do. Left as-is it read as a + // shared group everywhere downstream -- the TUI's `length === 1` test + // marked `a` optional while its submit guard still refused a blank, and the + // web panel offered the useless hint "Any one of: a, a". + groups.push([...new Set(part.names)]); } return groups; } From c6b17c52419198c691ef5928b67aaf9ad6f46712 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:47:19 -0400 Subject: [PATCH 212/941] fix: refuse a stray brace, bound value length, and drop a quadratic scan Addresses Copilot's round-14 review on #2035. Three suppressed comments, all real; each measured on 1c595e96 before acting. 1. A brace surviving in a literal is an unmatched one, and not a legal literal character. The SDK's constructor rejects an unclosed `{` but reads a stray `}` as text, and this parser did the same: expandUriTemplateStrict("x://a}", {}) => "x://a}" -- a "URI" carrying a brace, with the panel enabling the read on it. Both halves are now refused the same way, so the withhold behavior covers the whole class rather than the half the SDK happened to catch. 2. Replacing `UriTemplate.expand` also removed its 1,000,000-character per-value ceiling. Nothing else bounded what reached the allocation-heavy encoders, so a pasted or programmatically supplied megabyte-plus value went straight through. Restored at the SDK's own limit -- the guard it was, not a new policy -- and the error names the offending variable. 3. `templateVariables` re-scanned an expression's whole varspec list for every one of its names to compute `conforming`, which is quadratic in a multi-name expression. Templates are server-controlled and the SDK admits one up to 1 MB, so a single expression can carry thousands of short comma-separated names, and this runs on the render thread. Iterating the varspecs supplies the name and its conformance in one pass. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 54 +++++++++++++++++++ core/mcp/uriTemplate.ts | 44 +++++++++++++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index a560eed0..c5358b54 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -801,6 +801,60 @@ describe("a value defined as the empty string", () => { }); }); +describe("unmatched braces", () => { + // The SDK's constructor rejects an unclosed `{` but reads a stray `}` as + // literal text, and this parser does the same -- so `x://a}` used to expand + // to itself, a "URI" carrying a brace, with the panel enabling the read. + it.each([ + ["a stray closing brace", "x://a}"], + ["a closing brace after an expression", "x://{a}}"], + ["an unclosed opening brace", "x://a/{oops"], + ])("strict refuses %s", (_label, template) => { + // The stray `}` is ours to catch; the unclosed `{` the SDK's constructor + // already rejects ("Unclosed template expression"). Either way the read is + // withheld, which is the property under test. + expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow( + /Unmatched brace|Unclosed template expression/, + ); + }); + + it("leaves such a template unexpanded on the display path", () => { + expect(expandUriTemplate("x://a}", {})).toBe("x://a}"); + expect(expandUriTemplate("x://a/{oops", {})).toBe("x://a/{oops"); + }); + + it("still accepts a well-formed expression", () => { + expect(expandUriTemplate("x://{a}/b", { a: "1" })).toBe("x://1/b"); + }); +}); + +describe("the per-value length ceiling", () => { + // Replacing `UriTemplate.expand` dropped the SDK's own 1,000,000-character + // guard, leaving the allocation-heavy encoders unbounded for a pasted or + // programmatically supplied value. + it("refuses a value past the limit", () => { + const result = tryExpandUriTemplate("x://{v}", { + v: "a".repeat(1_000_001), + }); + expect(result.uri).toBeUndefined(); + expect(result.error).toMatch(/exceeds the 1000000-character limit/); + }); + + it("accepts a value at the limit", () => { + const value = "a".repeat(1_000_000); + expect(tryExpandUriTemplate("x://{v}", { v: value }).uri).toBe( + `x://${value}`, + ); + }); + + it("names the offending variable", () => { + expect( + tryExpandUriTemplate("x://{a}/{b}", { a: "1", b: "b".repeat(1_000_001) }) + .error, + ).toMatch(/"b"/); + }); +}); + describe("tryExpandUriTemplate", () => { it("returns the URI and no error when the template expands", () => { expect( diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 55f16543..485d5a79 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -270,10 +270,13 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { for (const part of parseUriTemplate(uriTemplate)) { if (part.kind !== "expression") continue; const required = !OMITTABLE_OPERATORS.has(part.operator); - for (const name of part.names) { - const conforming = !part.varspecs.some( - (spec) => spec.name === name && spec.conforming === false, - ); + // Iterating the varspecs (rather than the names, re-scanning the varspecs + // for each) keeps discovery linear. A template is server-controlled and the + // SDK admits one up to 1 MB, so a single expression can carry thousands of + // short comma-separated names -- and this runs on the render thread. + for (const spec of part.varspecs) { + const { name } = spec; + const conforming = spec.conforming !== false; const existing = byName.get(name); if (existing) { existing.required = existing.required || required; @@ -668,6 +671,33 @@ export function expandUriTemplateStrict( ): string { new UriTemplate(uriTemplate); const parts = parseUriTemplate(uriTemplate); + + // A brace left in a literal is an unmatched one, and it is not a legal + // literal character. The SDK's constructor rejects an unclosed `{` but reads + // a stray `}` as text, and this parser does the same, so without this check + // `x://a}` expanded to itself -- a "URI" carrying a brace, with the panel + // happily enabling the read. Both halves are now refused the same way. + const stray = parts.find( + (part) => part.kind === "literal" && /[{}]/.test(part.text), + ); + if (stray) { + throw new Error( + `Unmatched brace in "${uriTemplate}" — an expression must be a complete {…}.`, + ); + } + + // Replacing `UriTemplate.expand` also dropped its per-value ceiling, and + // nothing else bounded what reaches the allocation-heavy encoders below: a + // pasted or programmatically supplied megabyte-plus value hit them unguarded. + // Kept at the SDK's own limit so this is the guard it was, not a new policy. + for (const [name, value] of Object.entries(values)) { + if (value.length > MAX_VALUE_LENGTH) { + throw new Error( + `The value for "${name}" exceeds the ${MAX_VALUE_LENGTH}-character limit.`, + ); + } + } + const bad = parts.find((part) => part.kind === "expression" && part.invalid); if (bad) { throw new Error( @@ -679,6 +709,12 @@ export function expandUriTemplateStrict( return expandParts(parts, values); } +/** + * The pinned SDK's per-value ceiling, enforced here because this module no + * longer calls `UriTemplate.expand` — which is where that check lived. + */ +const MAX_VALUE_LENGTH = 1_000_000; + /** * The outcome of an expansion: the URI, or the reason there isn't one. * From a3dd85638059c4d14aa264f85b7754214745e448 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 00:59:30 -0400 Subject: [PATCH 213/941] test: pin the InspectorClient wiring, and correct a false claim about the SDK Addresses Copilot's round-15 review on #2035. 1. `readResourceFromTemplate` had no regression distinguishing this module from the SDK expander it replaced: the existing test uses `report.txt` and the transport tests expand separately and call `readResource`, so all of them pass either way. Writing that test corrected something I had asserted twice in this PR. The first version used `foo/bar` -- and verified by reverting the wiring, it still passed. `encodeURIComponent` DOES encode `/`, so the SDK expands `foobar://events/{topic}` to `foobar://events/foo%2Fbar` exactly as this module does. #1919 was the WEB PANEL's own `String.replace`, not the SDK expander, which the TUI path was using correctly all along. The test now uses `a!b`. `!` is a sub-delim: RFC 6570 requires it encoded, `encodeURIComponent` leaves it bare, so the SDK returns `foobar://events/a!b` and the assertion fails. Re-verified by reverting the wiring -- 2 of the 10 tests fail (this one and the malformed-template refusal), where before the change none did. 2. The module summary said "shared by every client" a dozen lines above the paragraph explaining the CLI is not a consumer. Now says web and TUI. Also records, on MAX_VALUE_LENGTH, the audit prompted by round 14: the per-value ceiling was the ONLY guard lost with `UriTemplate.expand`. The template-length (1e6), expression-count (1e4) and variable-name-length (1e6) limits all live in the SDK's `parse()`, which runs from the constructor -- and strict expansion still constructs a `UriTemplate`, so they remain enforced. The restored limit is the SDK's `MAX_VARIABLE_LENGTH`, read from the pinned dist rather than assumed. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../integration/mcp/rfc6570-templates.test.ts | 51 +++++++++++++++++++ core/mcp/uriTemplate.ts | 14 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts index 77b54eae..d71699af 100644 --- a/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts +++ b/clients/web/src/test/integration/mcp/rfc6570-templates.test.ts @@ -143,6 +143,57 @@ describe("RFC 6570 resource templates over the wire (#1919)", () => { expect(result.contents[0]?.uri).toBe(uri); }); + // The tests above expand with the helper and then call `readResource`, which + // leaves `InspectorClient.readResourceFromTemplate` -- the API the TUI drives + // and the one this PR rewired -- unproven: they pass whether or not that + // method still uses the SDK's expander. These drive it end to end with a + // value that the two expanders disagree about, so reverting the wiring fails + // here rather than silently. + it("expands and reads a simple expression through readResourceFromTemplate", async () => { + const connected = await connectToShowcase(); + const invocation = await connected.readResourceFromTemplate( + "foobar://events/{topic}", + { topic: "foo/bar" }, + ); + expect(invocation.expandedUri).toBe("foobar://events/foo%2Fbar"); + expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri); + }); + + it("encodes a sub-delim the SDK's expander leaves bare", async () => { + const connected = await connectToShowcase(); + // Verified by reverting the wiring: `encodeURIComponent` does encode `/`, + // so `foo/bar` alone does NOT distinguish the two expanders -- the SDK + // handles that case correctly and #1919 was the web panel's own string + // substitution. `!` is where they differ: it is a sub-delim, which RFC 6570 + // requires encoded and `encodeURIComponent` leaves alone, so the SDK + // expander returns `foobar://events/a!b` and fails this assertion. + const invocation = await connected.readResourceFromTemplate( + "foobar://events/{topic}", + { topic: "a!b" }, + ); + expect(invocation.expandedUri).toBe("foobar://events/a%21b"); + expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri); + }); + + it("expands and reads a query expression through readResourceFromTemplate", async () => { + const connected = await connectToShowcase(); + const invocation = await connected.readResourceFromTemplate( + "foobar://events{?topic}", + { topic: "foo/bar" }, + ); + expect(invocation.expandedUri).toBe("foobar://events?topic=foo%2Fbar"); + expect(invocation.result.contents[0]?.uri).toBe(invocation.expandedUri); + }); + + it("refuses the malformed template through readResourceFromTemplate", async () => { + const connected = await connectToShowcase(); + await expect( + connected.readResourceFromTemplate("foobar://events/{topic:abc}", { + topic: "news", + }), + ).rejects.toThrow(/Invalid RFC 6570 varspec/); + }); + it("resolves the base URI a blank query expression expands to", async () => { const connected = await connectToShowcase(); // RFC 6570 drops the whole expression when the variable is *undefined*, so diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 485d5a79..5ffc786d 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -1,5 +1,6 @@ /** - * RFC 6570 URI Template parsing and expansion, shared by every client (#1919). + * RFC 6570 URI Template parsing and expansion, shared by the web and TUI + * clients (#1919). * * This lives in `core/` rather than in a client so the web Resources form and * the TUI cannot disagree about what a template means. Both derive their form @@ -710,8 +711,15 @@ export function expandUriTemplateStrict( } /** - * The pinned SDK's per-value ceiling, enforced here because this module no - * longer calls `UriTemplate.expand` — which is where that check lived. + * The pinned SDK's per-value ceiling (`MAX_VARIABLE_LENGTH`, 1e6), enforced + * here because this module no longer calls `UriTemplate.expand` — which is + * where that check lived. + * + * It is the only guard that moved. Auditing the rest of the SDK's expander + * against what this replaced: the template-length (1e6), expression-count + * (1e4) and variable-name-length (1e6) limits all live in `parse()`, which + * runs from the **constructor** — and {@link expandUriTemplateStrict} still + * constructs a `UriTemplate`, so those remain enforced by it. */ const MAX_VALUE_LENGTH = 1_000_000; From 7d9410cbe004163d466d00bc123e702375ccf13c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 01:13:17 -0400 Subject: [PATCH 214/941] fix: keep a pct-encoded UTF-8 sequence whole, and bound the preview too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-16 review on #2035. Two suppressed comments, both real, and both refinements of fixes from the previous two rounds rather than defects in the original code. 1. Round 11 settled on "a pct-encoded triplet counts as one character", but a multi-octet character spans several triplets -- `%C3%A9` is one `é`. Measured on a3dd8563: x{+v:1} v = "%C3%A9x" => "x%C3" a lone lead byte, decoding to nothing `prefixUnits` now recognizes a well-formed pct-encoded UTF-8 sequence from its lead byte (RFC 3629: C2-DF two octets, E0-EF three, F0-F4 four; continuations 80-BF), longest alternative first so a four-octet sequence is not read as a two-octet one followed by loose triplets. A malformed sequence -- a lead byte with too few continuations, or a stray continuation -- matches no sequence alternative and falls back to per-triplet counting. That is the conservative direction: it counts as MORE units, so truncation keeps less rather than emitting octets that were never in the value. 2. Round 14's per-value ceiling only covered the read. The panel calls `previewUriTemplate` on every keystroke, and that path expands expression by expression, so it reached the allocation-heavy encoders with the same oversized value -- submission refused while the UI froze on the paste anyway. Both paths now share one exported `valueLengthError`, checked before any encoding happens. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 33 +++++++++++ clients/web/src/utils/uriTemplate.test.ts | 16 +++++ clients/web/src/utils/uriTemplate.ts | 8 +++ core/mcp/uriTemplate.ts | 58 ++++++++++++++----- 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index c5358b54..875508a3 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -9,6 +9,7 @@ import { templateVariables, tryExpandUriTemplate, unmetRequiredGroups, + valueLengthError, } from "@inspector/core/mcp/uriTemplate.js"; describe("parseUriTemplate", () => { @@ -749,6 +750,26 @@ describe("prefix modifier truncation", () => { }, ); + it.each([ + // A pct-encoded UTF-8 sequence is ONE character: `%C3%A9` is `é`. Counting + // per triplet returned `%C3`, a lone lead byte decoding to nothing. + ["x{+v:1}", "%C3%A9x", "x%C3%A9"], + ["x{+v:2}", "%C3%A9x", "x%C3%A9x"], + // Three-octet (`€`) and four-octet (an emoji) sequences, same rule. + ["x{+v:1}", "%E2%82%ACy", "x%E2%82%AC"], + ["x{+v:1}", "%F0%9F%98%80z", "x%F0%9F%98%80"], + // A malformed sequence -- a lead byte with too few continuations -- falls + // back to per-triplet counting rather than swallowing what follows. + ["x{+v:1}", "%C3x", "x%C3"], + // A stray continuation byte is not a sequence start. + ["x{+v:1}", "%A9%C3%A9", "x%A9"], + ])( + "keeps a multi-octet sequence whole (%s over %s)", + (template, value, expected) => { + expect(expandUriTemplate(template, { v: value })).toBe(expected); + }, + ); + it("counts a triplet as one character under a named operator too", () => { expect(expandUriTemplate("x{?v:1}", { v: "%61%62" })).toBe("x?v=%2561"); }); @@ -855,6 +876,18 @@ describe("the per-value length ceiling", () => { }); }); +describe("valueLengthError", () => { + it("names the first oversized value", () => { + expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch( + /"b" exceeds the 1000000-character limit/, + ); + }); + + it("passes values at or under the limit", () => { + expect(valueLengthError({ a: "a".repeat(1_000_000) })).toBeNull(); + }); +}); + describe("tryExpandUriTemplate", () => { it("returns the URI and no error when the template expands", () => { expect( diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index 72e5f824..cdbac17c 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -126,3 +126,19 @@ describe("previewUriTemplate - it must never throw or over-promise", () => { ); }); }); + +describe("previewUriTemplate - the value ceiling", () => { + it("falls back before encoding an oversized value", () => { + // Without this the guard was half a guard: the read was refused while the + // preview still encoded the same value on every keystroke, so a large + // paste froze the UI anyway. + expect(previewUriTemplate("x://{v}", { v: "a".repeat(1_000_001) })).toBe( + "x://{v}", + ); + }); + + it("still previews a value at the limit", () => { + const value = "a".repeat(1_000_000); + expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 81ecd3b3..46c8077a 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -15,6 +15,7 @@ import { encodeLiteral, expandTemplateExpression, parseUriTemplate, + valueLengthError, } from "@inspector/core/mcp/uriTemplate.js"; export { @@ -27,6 +28,7 @@ export { templateVariables, tryExpandUriTemplate, unmetRequiredGroups, + valueLengthError, } from "@inspector/core/mcp/uriTemplate.js"; export type { TemplateExpansion, @@ -71,6 +73,12 @@ export function previewUriTemplate( ): string { const defined = definedValues(values); + // The same ceiling `tryExpandUriTemplate` enforces for the read. Without it + // the guard was half a guard: submission was refused while this path still + // handed the oversized value to the encoders on every keystroke, so a large + // paste froze the UI anyway. Checked before any encoding happens. + if (valueLengthError(defined) !== null) return uriTemplate; + try { return parseUriTemplate(uriTemplate) .map((part) => { diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 5ffc786d..a0a53cfb 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -512,6 +512,27 @@ function encodeValue(value: string, operator: string): string { : encodeUnreserved(value); } +/** + * One unit a prefix modifier counts: a code point, a pct-encoded octet, or a + * whole pct-encoded UTF-8 *sequence*. + * + * The sequence alternatives are what stop a multi-octet character being cut in + * half. `%C3%A9` is one character — `é` — so `{+v:1}` over `%C3%A9x` must keep + * both octets; counting per triplet returned `%C3`, a lone lead byte that + * decodes to nothing. The lead byte announces the length (RFC 3629: `C2-DF` + * two octets, `E0-EF` three, `F0-F4` four) and each continuation is `80-BF`, + * so a well-formed sequence is recognizable without decoding. + * + * Order matters: longest first, so a four-octet sequence is not matched as a + * two-octet one followed by loose triplets. A malformed sequence — a lead byte + * with too few continuations, or a stray continuation — matches no sequence + * alternative and falls through to the single-triplet rule, which is the + * conservative behavior: it counts as more units, so truncation keeps less + * rather than emitting something that was never in the value. + */ +const PREFIX_UNIT = + /%F[0-4](?:%[89AB][0-9A-F]){3}|%E[0-9A-F](?:%[89AB][0-9A-F]){2}|%[CD][0-9A-F]%[89AB][0-9A-F]|%[0-9A-F]{2}|[\s\S]/giu; + /** * Splits a value into the units a prefix modifier counts. * @@ -532,12 +553,12 @@ function encodeValue(value: string, operator: string): string { * still escapes the `%`, so the retained `%61` goes out as `%2561`), not what * counts as one. * - * The `u` flag makes the alternation match by code point, so an astral + * The `u` flag makes the final alternative match by code point, so an astral * character is one unit rather than a surrogate pair that truncation could * split. */ function prefixUnits(value: string): string[] { - return value.match(/%[0-9A-Fa-f]{2}|[\s\S]/gu) ?? []; + return value.match(PREFIX_UNIT) ?? []; } /** @@ -687,17 +708,8 @@ export function expandUriTemplateStrict( ); } - // Replacing `UriTemplate.expand` also dropped its per-value ceiling, and - // nothing else bounded what reaches the allocation-heavy encoders below: a - // pasted or programmatically supplied megabyte-plus value hit them unguarded. - // Kept at the SDK's own limit so this is the guard it was, not a new policy. - for (const [name, value] of Object.entries(values)) { - if (value.length > MAX_VALUE_LENGTH) { - throw new Error( - `The value for "${name}" exceeds the ${MAX_VALUE_LENGTH}-character limit.`, - ); - } - } + const tooLong = valueLengthError(values); + if (tooLong) throw new Error(tooLong); const bad = parts.find((part) => part.kind === "expression" && part.invalid); if (bad) { @@ -723,6 +735,26 @@ export function expandUriTemplateStrict( */ const MAX_VALUE_LENGTH = 1_000_000; +/** + * The first value exceeding {@link MAX_VALUE_LENGTH}, as a message, or `null`. + * + * Exported because the *preview* needs the same verdict and cannot get it from + * {@link expandUriTemplateStrict}: it expands expression by expression, so it + * would otherwise hand an oversized value straight to the encoders during + * render — the guard would hold for the read while the UI still froze on the + * paste that triggered it. + */ +export function valueLengthError( + values: Record<string, string>, +): string | null { + for (const [name, value] of Object.entries(values)) { + if (value.length > MAX_VALUE_LENGTH) { + return `The value for "${name}" exceeds the ${MAX_VALUE_LENGTH}-character limit.`; + } + } + return null; +} + /** * The outcome of an expansion: the URI, or the reason there isn't one. * From 05bbfe5074968c03e062115f0bc853df3c4ad700 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 01:25:58 -0400 Subject: [PATCH 215/941] fix: read the completions map by own property, not through the prototype Addresses Copilot's round-17 review on #2035. A real crash, and a class the earlier prototype work missed rather than a narrower version of it. `toString`, `constructor` and `__proto__` are valid RFC 6570 variable names, and `completions` starts as `{}`. So for such a name: completions["toString"] ?? [] => the prototype's FUNCTION `??` catches only null and undefined, so that function went to Mantine's Autocomplete as its `data`, and the field crashed on first render with `TypeError: data.map is not a function` -- reproduced by reverting the fix under the new test. The earlier rounds fixed this for the *values* map, which is seeded with every declared variable and so always has the key as an own property. The completions map is the one name-keyed map this component reads WITHOUT having seeded it, which is what made it the surviving instance. Both reads now go through `Object.hasOwn` -- the render, and the stale-dropdown check that clears a variable's options on the next keystroke. Only the autocomplete branch could reach it, so the regression drives that branch: completions supported, a `{toString}` template, type and submit. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../ResourceTemplatePanel.test.tsx | 24 +++++++++++++++++++ .../ResourceTemplatePanel.tsx | 23 ++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx index b7d6c934..083a11c6 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.test.tsx @@ -318,6 +318,30 @@ describe("ResourceTemplatePanel", () => { expect(screen.getByText("Still needed: a")).toBeInTheDocument(); }); + it("renders an autocomplete field for a name that collides with Object.prototype", async () => { + // `toString` is a valid RFC 6570 varname and `completions` starts empty, + // so a bare `completions[varName] ?? []` handed Mantine the prototype's + // *function* as its options array (`??` catches only null/undefined) and + // the field crashed on first render. + const user = userEvent.setup(); + const onCompleteArgument = vi.fn().mockResolvedValue(["a", "b"]); + const onReadResource = vi.fn(); + renderWithMantine( + <ResourceTemplatePanel + template={{ name: "Proto", uriTemplate: "x://{toString}" }} + onReadResource={onReadResource} + onCompleteArgument={onCompleteArgument} + completionsSupported + />, + ); + // By role, not label: "toString" also appears in the "Still needed" line. + const input = screen.getByRole("textbox", { name: "toString" }); + expect(input).toBeInTheDocument(); + await user.type(input, "v"); + await user.click(screen.getByRole("button", { name: "Read Resource" })); + expect(onReadResource).toHaveBeenCalledWith("x://v"); + }); + it("shows the malformed template unexpanded in the preview", () => { renderWithMantine( <ResourceTemplatePanel diff --git a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx index d1e7137d..e3884583 100644 --- a/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx +++ b/clients/web/src/components/groups/ResourceTemplatePanel/ResourceTemplatePanel.tsx @@ -47,6 +47,24 @@ export interface ResourceTemplatePanelProps { const COMPLETION_DEBOUNCE_MS = 300; +/** + * The completions fetched for one variable, ignoring anything inherited from + * `Object.prototype`. + * + * `toString`, `constructor` and `__proto__` are valid RFC 6570 variable names, + * and this map starts empty — so a bare `completions[varName] ?? []` returned + * the prototype's *function* for such a name (`??` only catches null and + * undefined) and handed it to Mantine as its `data` array, crashing the field + * on first render. Same hazard the expansion path fixed for values; this is + * the one place the component reads a name-keyed map it did not seed. + */ +function completionsFor( + completions: Record<string, string[]>, + varName: string, +): string[] { + return Object.hasOwn(completions, varName) ? completions[varName] : []; +} + const HeaderRow = Group.withProps({ justify: "space-between", wrap: "nowrap", @@ -206,7 +224,8 @@ export function ResourceTemplatePanel({ // show ghost suggestions from the old keystroke while the new // request is in flight (300ms debounce + network latency). setCompletions((prev) => { - if (prev[varName] === undefined) return prev; + // Own-property, as above: an inherited member is not a stale dropdown. + if (!Object.hasOwn(prev, varName)) return prev; const next = { ...prev }; delete next[varName]; return next; @@ -328,7 +347,7 @@ export function ResourceTemplatePanel({ description={description} placeholder={`Enter ${varName}`} value={fieldValue} - data={completions[varName] ?? []} + data={completionsFor(completions, varName)} // The server already filtered the values for the typed // prefix; passing options through verbatim avoids hiding // valid suggestions when the input is empty or doesn't From 9caac43da43c6c4d1329f681d0b9ffdbb9c7d7da Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 01:40:50 -0400 Subject: [PATCH 216/941] fix: accept only well-formed UTF-8 sequences, and validate before previewing Addresses Copilot's round-18 review on #2035. Two suppressed comments, both holes in my own recent fixes rather than in the original code. 1. Round 16's grouping keyed off the length-announcing lead byte alone, so it accepted sequences RFC 3629 forbids -- and the comment claiming they fell back to per-triplet counting was simply wrong. Measured on 05bbfe50: x{+v:1} "%C0%80x" => "x%C0%80" overlong "%E0%80%80x" => "x%E0%80%80" overlong "%ED%A0%80x" => "x%ED%A0%80" UTF-16 surrogate "%F4%90%80%80x" => "x%F4%90%80%80" past U+10FFFF The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so each of the above falls back to one triplet, which is what the fallback was always documented to do. The legal boundary cases (%E0%A0%80, %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges cannot quietly start splitting valid characters. 2. The preview never ran the read's template validation. It expands expression by expression, so `x://{a}}/{b}` with both fields filled previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse. Validation moved into an exported `templateError` that both callers consult, so they cannot drift. It subsumes the narrower rule round 12 added here ("leave the invalid part standing"), which still let the REST of such a template expand: the refusal is per template, not per expression, so the preview now shows the template verbatim -- literals unencoded included, since half-normalizing an unreadable template invents a string the server never published. Two existing tests asserted the weaker behavior and now assert this one. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 38 +++++++++ clients/web/src/utils/uriTemplate.test.ts | 25 +++--- clients/web/src/utils/uriTemplate.ts | 19 ++++- core/mcp/uriTemplate.ts | 81 ++++++++++++++----- 4 files changed, 130 insertions(+), 33 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 875508a3..b0d38f03 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -7,6 +7,7 @@ import { parseUriTemplate, requiredGroups, templateVariables, + templateError, tryExpandUriTemplate, unmetRequiredGroups, valueLengthError, @@ -763,6 +764,17 @@ describe("prefix modifier truncation", () => { ["x{+v:1}", "%C3x", "x%C3"], // A stray continuation byte is not a sequence start. ["x{+v:1}", "%A9%C3%A9", "x%A9"], + // RFC 3629 well-formedness, not just the length-announcing lead byte: an + // overlong encoding, a UTF-16 surrogate, and a code point past U+10FFFF + // are each refused the grouping and counted per triplet. + ["x{+v:1}", "%C0%80x", "x%C0"], + ["x{+v:1}", "%E0%80%80x", "x%E0"], + ["x{+v:1}", "%ED%A0%80x", "x%ED"], + ["x{+v:1}", "%F4%90%80%80x", "x%F4"], + // The boundary cases that ARE well-formed stay whole. + ["x{+v:1}", "%E0%A0%80x", "x%E0%A0%80"], + ["x{+v:1}", "%ED%9F%BFx", "x%ED%9F%BF"], + ["x{+v:1}", "%F4%8F%BF%BFx", "x%F4%8F%BF%BF"], ])( "keeps a multi-octet sequence whole (%s over %s)", (template, value, expected) => { @@ -876,6 +888,32 @@ describe("the per-value length ceiling", () => { }); }); +describe("templateError", () => { + it.each([ + ["a stray closing brace", "x://{a}}"], + ["an unclosed brace", "x://{oops"], + ["an invalid varspec", "x://{a,}"], + ["an out-of-grammar modifier", "x://{id:abc}"], + ])("reports %s", (_label, template) => { + expect(templateError(template)).not.toBeNull(); + }); + + it.each(["x://{a}", "x://a{?b,c}", "x://a{;d:3}", "café/{var}"])( + "passes the valid template %s", + (template) => { + expect(templateError(template)).toBeNull(); + }, + ); + + it("gives strict expansion its verdict, so the two cannot disagree", () => { + // Every template the error reports must also refuse to expand. + for (const template of ["x://{a}}", "x://{oops", "x://{a,}"]) { + expect(templateError(template)).not.toBeNull(); + expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow(); + } + }); +}); + describe("valueLengthError", () => { it("names the first oversized value", () => { expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch( diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index cdbac17c..a3133e22 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -93,10 +93,10 @@ describe("previewUriTemplate - literals", () => { expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}"); }); - it("keeps a malformed template legible rather than encoding its brace", () => { - expect(previewUriTemplate("x://café/{oops", {})).toBe( - "x://caf%C3%A9/{oops", - ); + it("shows a template the read would refuse exactly as the server wrote it", () => { + // Not even the literal is encoded: an unreadable template is displayed + // verbatim rather than half-normalized into something never published. + expect(previewUriTemplate("x://café/{oops", {})).toBe("x://café/{oops"); }); }); @@ -120,11 +120,18 @@ describe("previewUriTemplate - it must never throw or over-promise", () => { ); }); - it("still previews the valid expressions around an invalid one", () => { - expect(previewUriTemplate("x://{a,}/{b}", { a: "1", b: "2" })).toBe( - "x://{a,}/2", - ); - }); + it.each([ + ["an invalid varspec", "x://{a,}/{b}"], + ["a stray closing brace", "x://{a}}/{b}"], + ])( + "does not expand the valid expressions around %s either", + (_label, template) => { + // The refusal is per TEMPLATE, not per expression: every read of these + // is refused, so previewing `x://{a,}/2` or `x://1}/2` would promise a + // URI this form can never send. An earlier revision did exactly that. + expect(previewUriTemplate(template, { a: "1", b: "2" })).toBe(template); + }, + ); }); describe("previewUriTemplate - the value ceiling", () => { diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 46c8077a..06ea88a1 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -15,6 +15,7 @@ import { encodeLiteral, expandTemplateExpression, parseUriTemplate, + templateError, valueLengthError, } from "@inspector/core/mcp/uriTemplate.js"; @@ -25,6 +26,7 @@ export { hasRequiredValues, parseUriTemplate, requiredGroups, + templateError, templateVariables, tryExpandUriTemplate, unmetRequiredGroups, @@ -58,9 +60,13 @@ export type { * expand-the-whole-template implementation got for free from the lenient * `expandUriTemplate` it called: * - * - **An invalid expression is left standing.** `{a,}` still parses `a` into - * its varspecs, so filling `a` would preview `x://1` for a template whose - * submission is refused outright -- a URI the user could never send. + * - **A template the read would refuse is shown verbatim.** `{a,}` still + * parses `a` into its varspecs, and `x://{a}}/{b}` parses two perfectly good + * expressions around a stray `}`, so expanding either previewed a URI whose + * submission is refused outright. `templateError` is the read's own verdict, + * so consulting it first is what keeps the two from disagreeing -- and it + * subsumes the narrower "skip the invalid part" rule this had at first, + * which left the *rest* of such a template expanding. * - **The whole thing is wrapped.** This runs during render, where a throw * unmounts the panel instead of disabling its button, and encoding can throw * for real input: `encodeURIComponent` raises `URIError` on an unpaired @@ -79,11 +85,16 @@ export function previewUriTemplate( // paste froze the UI anyway. Checked before any encoding happens. if (valueLengthError(defined) !== null) return uriTemplate; + // The same verdict the read is gated on. Without it this expanded expression + // by expression regardless: `x://{a}}/{b}` with both fields filled previewed + // `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview + // promising something the form it sits in can never send. + if (templateError(uriTemplate) !== null) return uriTemplate; + try { return parseUriTemplate(uriTemplate) .map((part) => { if (part.kind === "literal") return encodeLiteral(part.text); - if (part.invalid) return part.source; // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are // valid RFC 6570 variable names, and a plain lookup would find // `Object.prototype`'s member and treat a blank field as filled. diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index a0a53cfb..025c95a8 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -524,14 +524,32 @@ function encodeValue(value: string, operator: string): string { * so a well-formed sequence is recognizable without decoding. * * Order matters: longest first, so a four-octet sequence is not matched as a - * two-octet one followed by loose triplets. A malformed sequence — a lead byte - * with too few continuations, or a stray continuation — matches no sequence - * alternative and falls through to the single-triplet rule, which is the - * conservative behavior: it counts as more units, so truncation keeps less - * rather than emitting something that was never in the value. + * two-octet one followed by loose triplets. + * + * The lead-byte ranges are RFC 3629's **well-formed** ones, not merely the + * length-announcing prefixes: `C2-DF` (so the overlong `%C0%80` is excluded), + * `E0` only with `A0-BF`, `ED` only with `80-9F` (excluding UTF-16 + * surrogates), `F0` only with `90-BF`, `F4` only with `80-8F` (nothing past + * U+10FFFF). An earlier revision accepted the whole `C0-DF`/`E0-EF`/`F0-F4` + * space and so grouped those invalid sequences as one character — the + * opposite of the fallback this comment promised. + * + * A malformed sequence — an excluded lead byte, too few continuations, or a + * stray continuation — now matches no sequence alternative and falls through + * to the single-triplet rule, which is the conservative behavior: it counts as + * more units, so truncation keeps less rather than emitting something that was + * never in the value. */ -const PREFIX_UNIT = - /%F[0-4](?:%[89AB][0-9A-F]){3}|%E[0-9A-F](?:%[89AB][0-9A-F]){2}|%[CD][0-9A-F]%[89AB][0-9A-F]|%[0-9A-F]{2}|[\s\S]/giu; +const CONTINUATION = String.raw`%[89AB][0-9A-F]`; +/** RFC 3629 well-formed sequences only — `C2-DF`, and the restricted second + * bytes that exclude overlongs (`E0%A0-BF`, `F0%90-BF`), UTF-16 surrogates + * (`ED%80-9F`) and anything past U+10FFFF (`F4%80-8F`). */ +const UTF8_SEQUENCE = [ + String.raw`(?:%F0%(?:9[0-9A-F]|[AB][0-9A-F])|%F[1-3]${CONTINUATION}|%F4%8[0-9A-F])${CONTINUATION}${CONTINUATION}`, + String.raw`(?:%E0%[AB][0-9A-F]|%E[1-9ABCEF]${CONTINUATION}|%ED%[89][0-9A-F])${CONTINUATION}`, + String.raw`%(?:C[2-9A-F]|D[0-9A-F])${CONTINUATION}`, +].join("|"); +const PREFIX_UNIT = new RegExp(`${UTF8_SEQUENCE}|%[0-9A-F]{2}|[\\s\\S]`, "giu"); /** * Splits a value into the units a prefix modifier counts. @@ -691,7 +709,36 @@ export function expandUriTemplateStrict( uriTemplate: string, values: Record<string, string>, ): string { - new UriTemplate(uriTemplate); + const invalid = templateError(uriTemplate); + if (invalid) throw new Error(invalid); + + const tooLong = valueLengthError(values); + if (tooLong) throw new Error(tooLong); + + return expandParts(parseUriTemplate(uriTemplate), values); +} + +/** + * Why `uriTemplate` cannot be expanded **at all**, independent of any values, + * or `null` if it can. + * + * Exported because the preview needs the same verdict before it expands + * anything: it goes expression by expression, so without this it happily + * rendered `x://1}/2` for `x://{a}}/{b}` — a template whose stray `}` makes + * every read refuse, i.e. a URI the form it sits in can never send. + * + * Three sources, deliberately in one place so the two callers cannot drift: + * the SDK's constructor (an unclosed `{`, and its template-length, + * expression-count and variable-name-length limits), a brace surviving in a + * literal, and a varspec this module's own grammar rejects. + */ +export function templateError(uriTemplate: string): string | null { + try { + new UriTemplate(uriTemplate); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + const parts = parseUriTemplate(uriTemplate); // A brace left in a literal is an unmatched one, and it is not a legal @@ -703,23 +750,17 @@ export function expandUriTemplateStrict( (part) => part.kind === "literal" && /[{}]/.test(part.text), ); if (stray) { - throw new Error( - `Unmatched brace in "${uriTemplate}" — an expression must be a complete {…}.`, - ); + return `Unmatched brace in "${uriTemplate}" — an expression must be a complete {…}.`; } - const tooLong = valueLengthError(values); - if (tooLong) throw new Error(tooLong); - const bad = parts.find((part) => part.kind === "expression" && part.invalid); if (bad) { - throw new Error( - `Invalid RFC 6570 varspec in "${uriTemplate}": ${ - bad.kind === "expression" ? bad.source : "" - } — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`, - ); + return `Invalid RFC 6570 varspec in "${uriTemplate}": ${ + bad.kind === "expression" ? bad.source : "" + } — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`; } - return expandParts(parts, values); + + return null; } /** From 7bcf155e06ef10803ac44029aae7ca871245d76e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 01:54:37 -0400 Subject: [PATCH 217/941] fix: scope the value ceiling to the names a template references Addresses Copilot's round-19 review on #2035 -- a divergence introduced by my own round-14 guard rather than by the original code. The SDK validated a value inside `encodeValue`, i.e. only after looking one up by a DECLARED name. The restored guard checked the caller's whole map, so an unrelated entry failed a template that never mentions it. Measured on 9caac43d: tryExpandUriTemplate("x://{id}", { id: "7", stale: "a".repeat(1_000_001) }) => error: The value for "stale" exceeds the 1000000-character limit RFC 6570 ignores an extra variable, so this refused a perfectly valid read. `valueLengthError` now takes the names to consider and both callers pass the template's declared set. Copilot's second half is worth taking on its own: strict expansion parsed the template THREE times -- inside `templateError`, again for the name list, again to expand -- and the template is server-controlled and may be 1 MB. It is now parsed once and reused, with validation split into `sdkTemplateError` (the constructor's unclosed-brace and length/count limits) and `partsError` (this module's own grammar), so `templateError` and strict expansion share both halves rather than duplicating either. npm run ci passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../web/src/test/core/mcp/uriTemplate.test.ts | 21 +++++++ clients/web/src/utils/uriTemplate.test.ts | 9 +++ clients/web/src/utils/uriTemplate.ts | 20 ++++--- core/mcp/uriTemplate.ts | 55 ++++++++++++++++--- 4 files changed, 91 insertions(+), 14 deletions(-) diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index b0d38f03..687d7a3f 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -880,6 +880,27 @@ describe("the per-value length ceiling", () => { ); }); + it("ignores an oversized value the template never references", () => { + // RFC 6570 ignores an extra variable, and the SDK path this replaced + // validated a value only after looking one up by declared name -- so + // checking the whole map refused `x://{id}` over a stale key beside it. + expect( + tryExpandUriTemplate("x://{id}", { + id: "7", + stale: "a".repeat(1_000_001), + }).uri, + ).toBe("x://7"); + }); + + it("scopes valueLengthError to the names it is given", () => { + const values = { id: "7", stale: "a".repeat(1_000_001) }; + expect(valueLengthError(values, ["id"])).toBeNull(); + expect(valueLengthError(values, ["id", "stale"])).not.toBeNull(); + // Unscoped, every entry is examined -- the behavior the preview and the + // read both moved off. + expect(valueLengthError(values)).not.toBeNull(); + }); + it("names the offending variable", () => { expect( tryExpandUriTemplate("x://{a}/{b}", { a: "1", b: "b".repeat(1_000_001) }) diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index a3133e22..1b9eacc4 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -144,6 +144,15 @@ describe("previewUriTemplate - the value ceiling", () => { ); }); + it("ignores an oversized value the template never references", () => { + expect( + previewUriTemplate("x://{id}", { + id: "7", + stale: "a".repeat(1_000_001), + }), + ).toBe("x://7"); + }); + it("still previews a value at the limit", () => { const value = "a".repeat(1_000_000); expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 06ea88a1..7d55b8c2 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -11,6 +11,7 @@ */ import { + declaredNames, definedValues, encodeLiteral, expandTemplateExpression, @@ -79,20 +80,25 @@ export function previewUriTemplate( ): string { const defined = definedValues(values); - // The same ceiling `tryExpandUriTemplate` enforces for the read. Without it - // the guard was half a guard: submission was refused while this path still - // handed the oversized value to the encoders on every keystroke, so a large - // paste froze the UI anyway. Checked before any encoding happens. - if (valueLengthError(defined) !== null) return uriTemplate; - // The same verdict the read is gated on. Without it this expanded expression // by expression regardless: `x://{a}}/{b}` with both fields filled previewed // `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview // promising something the form it sits in can never send. if (templateError(uriTemplate) !== null) return uriTemplate; + const parts = parseUriTemplate(uriTemplate); + + // The same ceiling `tryExpandUriTemplate` enforces for the read, scoped the + // same way -- to the names this template references. Without it the guard was + // half a guard: submission was refused while this path still handed the + // oversized value to the encoders on every keystroke, so a large paste froze + // the UI anyway. Checked before any encoding happens. + if (valueLengthError(defined, declaredNames(parts)) !== null) { + return uriTemplate; + } + try { - return parseUriTemplate(uriTemplate) + return parts .map((part) => { if (part.kind === "literal") return encodeLiteral(part.text); // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 025c95a8..a536dde3 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -709,13 +709,21 @@ export function expandUriTemplateStrict( uriTemplate: string, values: Record<string, string>, ): string { - const invalid = templateError(uriTemplate); + const sdk = sdkTemplateError(uriTemplate); + if (sdk) throw new Error(sdk); + + // Parsed ONCE and reused for validation, the name list and the expansion -- + // the template is server-controlled and may be up to 1 MB, so re-parsing it + // per step is real work on the render thread. + const parts = parseUriTemplate(uriTemplate); + + const invalid = partsError(uriTemplate, parts); if (invalid) throw new Error(invalid); - const tooLong = valueLengthError(values); + const tooLong = valueLengthError(values, declaredNames(parts)); if (tooLong) throw new Error(tooLong); - return expandParts(parseUriTemplate(uriTemplate), values); + return expandParts(parts, values); } /** @@ -733,14 +741,27 @@ export function expandUriTemplateStrict( * literal, and a varspec this module's own grammar rejects. */ export function templateError(uriTemplate: string): string | null { + return ( + sdkTemplateError(uriTemplate) ?? + partsError(uriTemplate, parseUriTemplate(uriTemplate)) + ); +} + +/** + * What the SDK's constructor rejects: an unclosed `{`, and its template-length + * (1e6), expression-count (1e4) and variable-name-length (1e6) limits. + */ +function sdkTemplateError(uriTemplate: string): string | null { try { new UriTemplate(uriTemplate); + return null; } catch (error) { return error instanceof Error ? error.message : String(error); } +} - const parts = parseUriTemplate(uriTemplate); - +/** What this module's own grammar rejects, given the already-parsed template. */ +function partsError(uriTemplate: string, parts: TemplatePart[]): string | null { // A brace left in a literal is an unmatched one, and it is not a legal // literal character. The SDK's constructor rejects an unclosed `{` but reads // a stray `}` as text, and this parser does the same, so without this check @@ -779,6 +800,13 @@ const MAX_VALUE_LENGTH = 1_000_000; /** * The first value exceeding {@link MAX_VALUE_LENGTH}, as a message, or `null`. * + * `names` limits the check to the variables a template actually references, + * and callers should pass it. RFC 6570 ignores an extra variable, and the SDK + * path this replaced validated a value only *after* looking one up by declared + * name — so checking the whole map made an unrelated entry fail a template + * that never mentions it: `x://{id}` with a valid `id` was refused because + * some stale megabyte-long key sat beside it in the caller's map. + * * Exported because the *preview* needs the same verdict and cannot get it from * {@link expandUriTemplateStrict}: it expands expression by expression, so it * would otherwise hand an oversized value straight to the encoders during @@ -787,15 +815,28 @@ const MAX_VALUE_LENGTH = 1_000_000; */ export function valueLengthError( values: Record<string, string>, + names?: Iterable<string>, ): string | null { - for (const [name, value] of Object.entries(values)) { - if (value.length > MAX_VALUE_LENGTH) { + const considered = + names === undefined ? Object.keys(values) : [...new Set(names)]; + for (const name of considered) { + const value = readValue(values, name); + if (value !== undefined && value.length > MAX_VALUE_LENGTH) { return `The value for "${name}" exceeds the ${MAX_VALUE_LENGTH}-character limit.`; } } return null; } +/** The variable names a parsed template references, in order, deduplicated. */ +export function declaredNames(parts: TemplatePart[]): string[] { + return [ + ...new Set( + parts.flatMap((part) => (part.kind === "expression" ? part.names : [])), + ), + ]; +} + /** * The outcome of an expansion: the URI, or the reason there isn't one. * From 6b27e5f9325e7c4e51be201d28a92924fb2fd611 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 09:45:57 -0400 Subject: [PATCH 218/941] chore: ignore the web client's tsup build output in ESLint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clients/web emits two bundles — dist (the Vite SPA) and build (the tsup prod-server runner) — but only dist was in globalIgnores, so `npm run lint` parsed ~1.2MB of generated output and reported an unused-eslint-disable warning from inside the vendored undici source. Its three sibling clients already ignore their build/ output. Closes #2043 Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/eslint.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/clients/web/eslint.config.js b/clients/web/eslint.config.js index 21726da5..bdc636bb 100644 --- a/clients/web/eslint.config.js +++ b/clients/web/eslint.config.js @@ -9,7 +9,12 @@ import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores(["dist", "storybook-static", "coverage"]), + // Generated output, none of it first-party source. Web is the only client + // that emits *two* bundles — `dist` (the Vite SPA) and `build` (the tsup + // prod-server runner, which vendors ~1.2MB of `undici`) — and only the first + // was originally listed, so `lint` reported a warning from inside undici's + // own source that nobody could act on (#2043). + globalIgnores(["build", "dist", "storybook-static", "coverage"]), { files: ["**/*.{ts,tsx}"], extends: [ From 4fd7f39acf2f886b897ff7b79cb200a0de421477 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 10:14:29 -0400 Subject: [PATCH 219/941] docs: state that build output is never a lint/format/typecheck target Adds an AGENTS.md rule under the pre-push gate naming the gated surface (first-party source) and everything excluded from it (each client's build/, web's dist/ and storybook-static/, coverage/, test-servers/build, core/**/{build,dist}, *.tsbuildinfo), why gating generated output is harmful even when it only warns, and the fact that the three coverage guards assert the converse and so cannot catch this drift. Mirrored into .github/copilot-instructions.md per the maintenance rules. Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/copilot-instructions.md | 1 + AGENTS.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 63b3edef..56959324 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,6 +103,7 @@ Both exist and do different jobs. Theme files (`src/theme/<Component>.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook). `npm run validate` is the fast inner-loop check and is **not** a substitute — it runs `test`, not `test:coverage`, so it does zero coverage gating. - **A dependency bump must land in every install that declares it.** v2 is not a workspace — the root and each `clients/*` have their own `node_modules`, and a client's test project compiles `core/` and `test-servers/src` (which resolve from the **root**) alongside the client's own sources. Bumping a shared dependency in one manifest only puts two versions of it in one `tsc` program; for a recursive-generic surface like zod that exhausts the tsc heap (#1896). `verify:dep-lockstep` fails the build on this — it derives its candidates from what each `tsc` program actually resolves (`tsc --listFilesOnly`, keeping packages that reach one program from two installs), so a package reached only through another package's `.d.ts` counts too (#1965) — so a PR bumping a package the shared sources pull in should update the root **and every client that already lists it** — not every client unconditionally, since a package absent from an install can't skew and adding it there would be a spurious dependency. - **A test or smoke must not touch real user state.** The web smokes run against a throwaway catalog via the shared `scripts/lib/prod-web-server.mjs` helper, never the developer's `~/.mcp-inspector/mcp.json` (#1977); the cli/tui smokes drive a temp `--catalog`. A new smoke spawning its own server, or teardown that removes a work dir without first awaiting `stopChild` (the #1801 race — `child-cleanup.mjs` exports both halves and both are required), should be flagged. +- **Build output is never a gate target.** Lint, format, and typecheck read first-party source only; everything a build writes (`clients/*/build`, `clients/web/dist`, `storybook-static`, `coverage`, `test-servers/build`, `core/**/{build,dist}`, `*.tsbuildinfo`) stays out via each scope's `globalIgnores`, `format` globs, and tsconfig `include`. Gating generated code reports defects in vendored third-party source that nobody can fix, and a rule promotion turns that warning into a `validate` failure (#2043). Flag a PR that adds a build location without ignoring it in the same change, that widens an ignore to silence a finding in first-party code, or that adds a build directory to a tsconfig `include` to make a generated `.d.ts` resolve. Note the coverage guards don't catch this — they assert source is *covered*, not that output is *excluded*. - **Every PR references an issue**, first body line `Closes #<ISSUE_NUMBER>`. - **Every PR carries exactly one version label**, `v1` or `v2`, matching its base branch. - **Commits carry a `Signed-off-by:` trailer.** The DCO check is a hard merge gate and fails on a single unsigned commit; it matches the trailer against the author *or* committer, and skips only merge and bot commits. Use `git commit -s` — note `format.signOff` does *not* sign `git commit` (only `format-patch`). Repairing pushed commits means `git rebase HEAD~<n> --signoff` + `git push --force-with-lease`; remediation commits are not enabled on this repo. diff --git a/AGENTS.md b/AGENTS.md index 7f18ee74..6a628c38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -748,6 +748,19 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - `smoke:cli` (`scripts/smoke-cli.mjs`) drives `mcp-inspector --cli` through the built launcher against the bundled stdio test server via a temp `--catalog`: it asserts `tools/list` returns the server's tools (real connect over stdio), the default writable catalog is seeded empty on first run, a missing read-only `--config` errors without seeding, and `--catalog` + `--config` is rejected. `smoke:tui` (`scripts/smoke-tui.mjs`) launches `mcp-inspector --tui --catalog <temp>` and asserts the Ink app renders its first frame (the "MCP Servers" panel) within a timeout, then SIGTERMs it — a shallow boot/render check, not full interaction. **`smoke:tui` is local-only: it self-skips when `process.env.CI` is set**, because the Ink TUI needs a real TTY (raw mode) that headless CI lacks — so run it (via `npm run smoke`) on your own machine before pushing. Both build `test-servers/build` on demand if it's missing. - Storybook play-function tests (`clients/web` `test:storybook`) run in headless Chromium via `@vitest/browser-playwright` (~10s). They are part of `npm run ci` (which installs Playwright chromium first); kept out of `validate` because they need the browser binary and are slower than the unit suite. +### Build output is never a gate target + +**No gate — lint, format, or typecheck — may read generated output.** The gated surface is first-party source only: `clients/*/src`, `clients/*/__tests__`, `clients/web/{server,.storybook}`, each client's top-level configs, `core/`, `test-servers/src`, `scripts/`, and the root shared files. Everything a build writes is out of scope: `clients/*/build` (the tsup/tsc bundles), `clients/web/dist` (the Vite SPA), `clients/web/storybook-static`, `clients/*/coverage`, `test-servers/build`, `core/**/{build,dist}`, and any `*.tsbuildinfo`. Each scope states this itself — `globalIgnores([...])` in every `eslint.config.js`, the `format`/`format:check` globs in each `package.json`, and a tsconfig `include` that names source directories rather than the package root. + +Why it matters, given that these paths are all gitignored and the findings are usually warnings: + +- **It reports defects nobody can fix.** A bundle vendors third-party code, so a rule that fires inside it names a problem in someone else's source. `clients/web` shipped this for a while: `build` was missing from its `globalIgnores` while its three sibling clients had it, so the client's *only* lint output was an unused-`eslint-disable` warning from inside the vendored `undici` (#2043). +- **A warning becomes a gate failure without warning.** `reportUnusedDisableDirectives` is a warning by default and a rule promotion — or any new rule a bundled dependency happens to trip — turns it into a `validate` failure on a file nobody wrote. #1959 (enabling `no-floating-promises` across all five scopes) is exactly that kind of change. +- **It trains people to skim the channel.** A scope whose lint is never clean has no signal left in it, and the real warning added later lands where everyone has learned to look past. +- **It is wasted work on every run.** `lint` runs inside `validate`, the fast inner-loop check, and the web runner bundle alone is ~1.2MB of generated JS. + +The three coverage guards do **not** catch this, and adding a fourth is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is *covered*; neither asserts that generated output is *excluded* — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types). + ### Typescript instructions - Use TypeScript for all new code From f8cf39ca4c75b0a54b637a150910c2d298b8d049 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 14:18:05 -0400 Subject: [PATCH 220/941] docs: say two coverage guards, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify:dep-lockstep is a durable guard but not a coverage guard — the two coverage guards are verify:format-coverage and verify:typecheck-coverage, which is the terminology the rest of the repo uses. The sentence said three and then named two. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6a628c38..0771175e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -759,7 +759,7 @@ Why it matters, given that these paths are all gitignored and the findings are u - **It trains people to skim the channel.** A scope whose lint is never clean has no signal left in it, and the real warning added later lands where everyone has learned to look past. - **It is wasted work on every run.** `lint` runs inside `validate`, the fast inner-loop check, and the web runner bundle alone is ~1.2MB of generated JS. -The three coverage guards do **not** catch this, and adding a fourth is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is *covered*; neither asserts that generated output is *excluded* — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types). +The two coverage guards do **not** catch this, and adding a third is not the fix. `verify:format-coverage` and `verify:typecheck-coverage` assert that first-party source is *covered*; neither asserts that generated output is *excluded* — an asymmetry that is deliberate, since a guard can't distinguish "generated" from "source" without being told, and the ignore lists are already that statement. So this class drifts silently and the check is a human one: **when a build starts writing to a new location, add it to that scope's ignore list in the same change.** The reverse of the guards' rule also holds — never widen an ignore to silence a finding in first-party code, and never add a build directory to a tsconfig `include` to make a generated `.d.ts` resolve (import the source, or fix the build's types). ### Typescript instructions From 3dfac1c3db1c5782eb9a63b2197a3aa248fc63f6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 14:30:58 -0400 Subject: [PATCH 221/941] fix: register SSE abort cleanup before the priming write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both SSE handlers installed their subscriber, primed the stream so Firefox's fetch() resolves (#1858), and only then registered onAbort. Hono's onAbort has no already-aborted replay and abort() fires only the subscribers registered at that moment, so a client disconnecting while the priming write was in flight fired nothing: the session event consumer / server-list subscriber stayed installed, the mcp.json watcher was never stopped, and the handler parked forever on a promise nothing would resolve. Add primeAndHoldSseStream, which registers one listener before the first await — it both cleans up and releases the hold, collapsing the previous duplicate onAbort registrations. Priming still happens after the subscriber is installed, since callers treat the stream's first bytes as proof they are subscribed. Closes #1999 Signed-off-by: cliffhall <cliff@futurescale.com> --- .../mcp/remote/sse-abort-cleanup.test.ts | 175 ++++++++++++++++++ core/mcp/remote/node/server.ts | 86 ++++++--- 2 files changed, 234 insertions(+), 27 deletions(-) create mode 100644 clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts diff --git a/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts new file mode 100644 index 00000000..a5fd5229 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts @@ -0,0 +1,175 @@ +/** + * Regression tests for #1999 — an SSE client that disconnects *while the + * priming write is in flight* used to leak the stream's subscriber and park + * the handler forever. + * + * Both SSE handlers in `core/mcp/remote/node/server.ts` install their + * subscriber (session event consumer / server-list subscriber + file watcher) + * synchronously, then prime the stream so Firefox's `fetch()` resolves + * (#1858), and only then registered their `onAbort` cleanup. Priming is an + * `await`, and Hono's `onAbort` has no already-aborted replay — so a + * disconnect inside that window fired zero listeners: the subscriber stayed + * installed, the `mcp.json` watcher was never stopped, and the promise the + * handler was holding open on never resolved. + * + * `primeAndHoldSseStream` is the fix: one listener, registered before the + * first `await`, that both cleans up and releases the hold. These tests drive + * it with a stream whose priming write is controllable, which is the only way + * to hit that window deterministically — against a real Hono stream the write + * settles on its own microtask. + */ + +import { describe, it, expect } from "vitest"; +import { StreamingApi } from "hono/utils/stream"; +import { + primeAndHoldSseStream, + type PrimableSseStream, +} from "@inspector/core/mcp/remote/node/server.js"; + +/** A stream whose priming write stays pending until the test releases it. */ +function controllableStream(): { + stream: PrimableSseStream; + /** Deliver the disconnect Hono would deliver. */ + abort: () => void; + /** Settle the pending priming write. */ + finishWrite: () => void; + writes: string[]; + closes: number; +} { + const listeners: (() => void)[] = []; + const writes: string[] = []; + let releaseWrite = (): void => {}; + const state = { closes: 0 }; + const stream: PrimableSseStream = { + write: (input: string) => { + writes.push(input); + return new Promise<void>((resolve) => { + releaseWrite = resolve; + }); + }, + onAbort: (listener: () => void) => { + listeners.push(listener); + }, + close: async () => { + state.closes += 1; + }, + }; + return { + stream, + abort: () => { + // Hono fires only the subscribers registered at this moment, with a + // bare `subscriber()` call — see `hono/utils/stream`. + for (const l of [...listeners]) l(); + }, + finishWrite: () => releaseWrite(), + writes, + get closes() { + return state.closes; + }, + }; +} + +/** Reject rather than hang if the handler is never released. */ +async function withinTick(promise: Promise<void>): Promise<void> { + const timeout = new Promise<never>((_, reject) => { + setTimeout(() => reject(new Error("handler was never released")), 500); + }); + await Promise.race([promise, timeout]); +} + +describe("primeAndHoldSseStream (#1999)", () => { + it("runs cleanup when the client aborts during the priming write", async () => { + const s = controllableStream(); + let cleanups = 0; + + const held = primeAndHoldSseStream(s.stream, () => { + cleanups += 1; + }); + + // The write is still pending — exactly the window the pre-fix code + // registered no listener in. + await Promise.resolve(); + expect(s.writes).toEqual([":\n\n"]); + expect(cleanups).toBe(0); + + s.abort(); + expect(cleanups).toBe(1); + + // The handler must also come back, or Hono never closes the stream. + s.finishWrite(); + await withinTick(held); + expect(s.closes).toBe(1); + }); + + it("primes the stream before holding it open", async () => { + const s = controllableStream(); + const held = primeAndHoldSseStream(s.stream, () => {}); + await Promise.resolve(); + + expect(s.writes).toEqual([":\n\n"]); + + s.finishWrite(); + s.abort(); + await withinTick(held); + }); + + it("holds the handler open until the abort, not merely until the write", async () => { + const s = controllableStream(); + let released = false; + const held = primeAndHoldSseStream(s.stream, () => {}).then(() => { + released = true; + }); + + s.finishWrite(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(released).toBe(false); + + s.abort(); + await withinTick(held); + expect(released).toBe(true); + }); + + it("runs cleanup once, on the first abort", async () => { + const s = controllableStream(); + let cleanups = 0; + const held = primeAndHoldSseStream(s.stream, () => { + cleanups += 1; + }); + + s.finishWrite(); + s.abort(); + await withinTick(held); + // Hono guards re-entry itself, but the helper must not depend on that. + expect(cleanups).toBe(1); + }); + + it("does not reject when cleanup's own close loses the race", async () => { + const s = controllableStream(); + const stream: PrimableSseStream = { + ...s.stream, + close: () => Promise.reject(new Error("peer already gone")), + }; + const held = primeAndHoldSseStream(stream, () => {}); + + s.finishWrite(); + s.abort(); + await expect(withinTick(held)).resolves.toBeUndefined(); + }); + + it("documents the Hono behavior it exists for: onAbort has no replay", async () => { + // The premise of the whole fix. If Hono ever replays the abort to a + // late subscriber, this fails and the ordering guard can be revisited. + const { readable, writable } = new TransformStream(); + const stream = new StreamingApi(writable, readable); + + await stream.responseReadable.cancel(); + + let late = 0; + stream.onAbort(() => { + late += 1; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(late).toBe(0); + }); +}); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 93000922..b837eac1 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -115,6 +115,50 @@ export function closeAbortedStream(stream: { close(): Promise<void> }): void { stream.close().catch(() => {}); } +/** The slice of Hono's `StreamingApi` the SSE prime-and-hold helper needs. */ +export interface PrimableSseStream { + write(input: string): Promise<unknown>; + onAbort(listener: () => void): void; + close(): Promise<void>; +} + +/** + * Prime an SSE stream, then hold the handler open until the client aborts, + * running `cleanup` exactly once when it does. + * + * The registration order is the whole point (#1999). Hono's `onAbort` is a + * plain push onto a subscriber array with **no already-aborted replay**, and + * `abort()` fires only the subscribers registered at that moment + * (`hono/utils/stream`). Priming is an `await`, so a listener registered + * *after* it never runs if the client disconnects while that write is in + * flight — leaving the caller's subscriber/consumer installed forever and the + * handler parked on a promise nothing will resolve. + * + * So the listener goes in before the first `await`, and the same one both + * cleans up and releases the hold. Note this must not be inlined back into a + * caller in a way that reintroduces an `await` ahead of the registration — + * priming itself cannot move earlier, because callers deliberately install + * their subscriber before the first bytes go out (see GET /api/servers/events). + * + * `cleanup` runs inside Hono's bare `subscriber()` call, so it must be + * synchronous and must not throw — a rejection there has no owner. + */ +export async function primeAndHoldSseStream( + stream: PrimableSseStream, + cleanup: () => void, +): Promise<void> { + const aborted = new Promise<void>((resolve) => { + stream.onAbort(() => { + cleanup(); + closeAbortedStream(stream); + resolve(); + }); + }); + + await stream.write(SSE_PRIMING_COMMENT); + await aborted; +} + /** * Shape of the initial config returned by GET /api/config (defaults for client). */ @@ -855,6 +899,10 @@ export function createRemoteApp( // drained the queued stderr + the transport_error event. Yield once // so the writeSSE writes flush, then return — closing the stream and // surfacing the real error instead of a bare 404 / silent hang. + // + // This branch needs no abort listener despite its `await`: it performs + // the same teardown unconditionally on the way out, so a disconnect + // during the yield changes nothing. if (session.isTransportDead()) { await new Promise((resolve) => setTimeout(resolve, 0)); session.clearEventConsumer(); @@ -864,29 +912,18 @@ export function createRemoteApp( // Prime only after the consumer is registered, so nothing observable // to the client happens before this stream can actually report - // events. See SSE_PRIMING_COMMENT. - await stream.write(SSE_PRIMING_COMMENT); - - stream.onAbort(() => { + // events. See SSE_PRIMING_COMMENT. The disconnect cleanup is + // registered ahead of that priming write and the stream is then held + // open until the client aborts — see primeAndHoldSseStream (#1999). + await primeAndHoldSseStream(stream, () => { // Client disconnected - clear event consumer const shouldCleanup = session.clearEventConsumer(); - closeAbortedStream(stream); // If transport is dead and no client connected, cleanup session if (shouldCleanup || session.isTransportDead()) { sessions.delete(sessionId); } }); - - // Keep the stream open until the client disconnects. Hono's streamSSE - // closes the stream when this callback returns, so we must not return - // until the connection is aborted. - await new Promise<void>((resolve) => { - stream.onAbort(() => { - // Cleanup happens in onAbort handler above - resolve(); - }); - }); }); }); @@ -2497,20 +2534,15 @@ export function createRemoteApp( // registered and the watcher started: callers treat the arrival of // this stream's first bytes as proof they are subscribed, so priming // first would hand out that proof across an `await`, before the - // watcher exists, and drop an edit made in the gap. - await stream.write(SSE_PRIMING_COMMENT); - - stream.onAbort(() => { + // watcher exists, and drop an edit made in the gap. The unsubscribe is + // nonetheless registered *before* that write, and the stream is held + // open until the client aborts — see primeAndHoldSseStream (#1999). + await primeAndHoldSseStream(stream, () => { serverEventSubscribers.delete(send); + // Voided rather than awaited: this runs inside Hono's synchronous + // abort subscriber, which cannot await. maybeStopWatcher owns its + // own failures (it swallows a close() that throws). void maybeStopWatcher(); - closeAbortedStream(stream); - }); - - // Hono closes the stream the moment this callback returns, so hold the - // promise open until the client aborts. Cleanup happens in the - // onAbort handler registered above. - await new Promise<void>((resolve) => { - stream.onAbort(() => resolve()); }); }); }); From e58c80440b658af719d601926d38999563aa45a8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 14:41:24 -0400 Subject: [PATCH 222/941] fix: guard the abort listener against a double invocation Copilot review round 1. The helper's contract is exactly-once teardown and the test asserted it must not lean on Hono's own re-entry guard, but neither the implementation nor the test actually enforced that: the listener had no local guard and the test aborted only once. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../test/integration/mcp/remote/sse-abort-cleanup.test.ts | 5 ++++- core/mcp/remote/node/server.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts index a5fd5229..a41c9a3d 100644 --- a/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts +++ b/clients/web/src/test/integration/mcp/remote/sse-abort-cleanup.test.ts @@ -138,9 +138,12 @@ describe("primeAndHoldSseStream (#1999)", () => { s.finishWrite(); s.abort(); + // Hono guards re-entry itself, but the helper must not depend on that — + // so drive the listener a second time directly. + s.abort(); await withinTick(held); - // Hono guards re-entry itself, but the helper must not depend on that. expect(cleanups).toBe(1); + expect(s.closes).toBe(1); }); it("does not reject when cleanup's own close loses the race", async () => { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index b837eac1..5bc58f5e 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -147,8 +147,15 @@ export async function primeAndHoldSseStream( stream: PrimableSseStream, cleanup: () => void, ): Promise<void> { + let done = false; const aborted = new Promise<void>((resolve) => { stream.onAbort(() => { + // Hono's `abort()` guards its own re-entry, but the guard belongs here + // too: this helper's contract is exactly-once teardown, and running a + // caller's cleanup twice would double-delete a subscriber that has + // since been re-added by a reconnect. + if (done) return; + done = true; cleanup(); closeAbortedStream(stream); resolve(); From de60983ccc2248f2c474d4d0ff9ec2c82163776c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Mon, 17 Aug 2026 20:40:23 -0400 Subject: [PATCH 223/941] fix: surface list-fetch failures in paginated mode (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1954 wired the list-load error off the aggregate managed stores only. With `paginatedLists` on, those stores deliberately skip their all-page walk, so their error is permanently null — while the paged stores that actually drive the sidebar had no error state at all and `loadPage` had a try/finally with no catch. A failing list showed an empty panel with no alert and no Retry, and the connect-time `void loadPage(undefined)` left an unhandled rejection. - Give PagedTools/Prompts/ResourcesState observable error state: an `errorChange` event plus `getError()`, set in a new catch in `loadPage`, cleared on the next success and on disconnect. The rejection is still re-thrown, matching ManagedListState — callers' auth-recovery wrappers key off it to detect a 401. - Catch the connect-time load's rejection (`.catch(() => {})`) now that the failure is recorded as state rather than lost. - Select the error by mode in `usePaginatedList`, the same way `items` already is, and read it in App.tsx from the pagination model. - Rename `useManagedListError` to `useListError`: both store families expose an identical `errorChange`, so the hook declares that one-event contract itself instead of importing ManagedListEventMap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.tsx | 17 ++++- .../web/src/hooks/usePaginatedList.test.tsx | 33 ++++++++++ clients/web/src/hooks/usePaginatedList.ts | 14 ++++ .../core/mcp/state/pagedPromptsState.test.ts | 65 +++++++++++++++++++ .../mcp/state/pagedResourcesState.test.ts | 65 +++++++++++++++++++ .../core/mcp/state/pagedToolsState.test.ts | 65 +++++++++++++++++++ ...stError.test.tsx => useListError.test.tsx} | 20 +++--- .../test/core/react/usePagedPrompts.test.tsx | 27 ++++++++ .../core/react/usePagedResources.test.tsx | 27 ++++++++ .../test/core/react/usePagedTools.test.tsx | 27 ++++++++ core/mcp/state/pagedPromptsState.ts | 39 ++++++++++- core/mcp/state/pagedResourcesState.ts | 39 ++++++++++- core/mcp/state/pagedToolsState.ts | 39 ++++++++++- ...useManagedListError.ts => useListError.ts} | 39 ++++++----- core/react/useManagedPrompts.ts | 4 +- core/react/useManagedResourceTemplates.ts | 4 +- core/react/useManagedResources.ts | 4 +- core/react/useManagedTools.ts | 4 +- core/react/usePagedPrompts.ts | 11 +++- core/react/usePagedResources.ts | 11 +++- core/react/usePagedTools.ts | 11 +++- 21 files changed, 523 insertions(+), 42 deletions(-) rename clients/web/src/test/core/react/{useManagedListError.test.tsx => useListError.test.tsx} (84%) rename core/react/{useManagedListError.ts => useListError.ts} (58%) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 91055747..3738044d 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1031,18 +1031,21 @@ function App() { tools: pagedTools, nextCursor: pagedToolsCursor, pageCount: pagedToolsPageCount, + error: pagedToolsLoadError, loadPage: loadToolsPage, } = usePagedTools(inspectorClient, pagedToolsState); const { prompts: pagedPrompts, nextCursor: pagedPromptsCursor, pageCount: pagedPromptsPageCount, + error: pagedPromptsLoadError, loadPage: loadPromptsPage, } = usePagedPrompts(inspectorClient, pagedPromptsState); const { resources: pagedResources, nextCursor: pagedResourcesCursor, pageCount: pagedResourcesPageCount, + error: pagedResourcesLoadError, loadPage: loadResourcesPage, } = usePagedResources(inspectorClient, pagedResourcesState); // The active server's persisted paginated setting drives the display mode. @@ -1073,9 +1076,11 @@ function App() { paginated: paginatedLists, managedItems: managedTools, managedRefresh: refreshTools, + managedError: toolsLoadError, pagedItems: pagedTools, pagedNextCursor: pagedToolsCursor, pagedPageCount: pagedToolsPageCount, + pagedError: pagedToolsLoadError, loadPage: loadToolsPage, }); const promptsPagination = usePaginatedList({ @@ -1083,9 +1088,11 @@ function App() { paginated: paginatedLists, managedItems: managedPrompts, managedRefresh: refreshPrompts, + managedError: promptsLoadError, pagedItems: pagedPrompts, pagedNextCursor: pagedPromptsCursor, pagedPageCount: pagedPromptsPageCount, + pagedError: pagedPromptsLoadError, loadPage: loadPromptsPage, }); const resourcesPagination = usePaginatedList({ @@ -1093,9 +1100,11 @@ function App() { paginated: paginatedLists, managedItems: managedResources, managedRefresh: refreshResources, + managedError: resourcesLoadError, pagedItems: pagedResources, pagedNextCursor: pagedResourcesCursor, pagedPageCount: pagedResourcesPageCount, + pagedError: pagedResourcesLoadError, loadPage: loadResourcesPage, }); const tools = toolsPagination.items; @@ -4407,9 +4416,11 @@ function App() { toolsListChanged={toolsListChanged} promptsListChanged={promptsListChanged} resourcesListChanged={resourcesListChanged} - toolsLoadError={toolsLoadError} - promptsLoadError={promptsLoadError} - resourcesLoadError={resourcesLoadError ?? resourceTemplatesLoadError} + toolsLoadError={toolsPagination.error} + promptsLoadError={promptsPagination.error} + resourcesLoadError={ + resourcesPagination.error ?? resourceTemplatesLoadError + } subscriptions={subscriptions} subscriptionStreamState={subscriptionStreamState} logs={logs} diff --git a/clients/web/src/hooks/usePaginatedList.test.tsx b/clients/web/src/hooks/usePaginatedList.test.tsx index 558716e8..c03e6a9e 100644 --- a/clients/web/src/hooks/usePaginatedList.test.tsx +++ b/clients/web/src/hooks/usePaginatedList.test.tsx @@ -7,9 +7,11 @@ interface Params { paginated: boolean; managedItems: string[]; managedRefresh: () => Promise<unknown>; + managedError: Error | null; pagedItems: string[]; pagedNextCursor?: string; pagedPageCount: number; + pagedError: Error | null; loadPage: (cursor?: string) => Promise<unknown>; } @@ -19,9 +21,11 @@ function makeParams(over: Partial<Params> = {}): Params { paginated: false, managedItems: ["m1", "m2"], managedRefresh: vi.fn(async () => []), + managedError: null, pagedItems: ["p1"], pagedNextCursor: undefined, pagedPageCount: 0, + pagedError: null, loadPage: vi.fn(async () => ({})), ...over, }; @@ -88,6 +92,35 @@ describe("usePaginatedList", () => { expect(loadPage).toHaveBeenCalledWith(undefined); }); + // #1998: the managed store deliberately never fetches in paginated mode, so + // reading its (permanently null) error there would leave a failed page load + // showing an empty panel with no alert and no Retry. + it("reports the managed error in all-pages mode", () => { + const managedError = new Error("aggregate failed"); + const pagedError = new Error("page failed"); + const params = makeParams({ managedError, pagedError }); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.error).toBe(managedError); + }); + + it("reports the paged error in paginated mode", () => { + const managedError = new Error("aggregate failed"); + const pagedError = new Error("page failed"); + const params = makeParams({ paginated: true, managedError, pagedError }); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.error).toBe(pagedError); + }); + + it("reports no error when the active source succeeded", () => { + const params = makeParams({ + paginated: true, + managedError: new Error("aggregate failed"), + pagedError: null, + }); + const { result } = renderHook(() => usePaginatedList(params)); + expect(result.current.error).toBeNull(); + }); + it("onRefresh re-fetches the aggregate in all-pages mode", () => { const managedRefresh = vi.fn(async () => []); const params = makeParams({ managedRefresh }); diff --git a/clients/web/src/hooks/usePaginatedList.ts b/clients/web/src/hooks/usePaginatedList.ts index 7729e592..9ece24e5 100644 --- a/clients/web/src/hooks/usePaginatedList.ts +++ b/clients/web/src/hooks/usePaginatedList.ts @@ -19,6 +19,13 @@ export interface PaginatedListModel<T> { * promise so the caller can wrap it in auth recovery. */ onLoadMore: () => Promise<unknown>; + /** + * The active source's last load failure, or `null`. Selected by mode for the + * same reason `items` is: in paginated mode the managed store never fetches, + * so its error is permanently `null` and reading it would leave a failed page + * showing an empty panel with no alert and no Retry (#1998). + */ + error: Error | null; /** * Refresh the list: reload page 1 in paginated mode, or re-fetch the whole * aggregate in all-pages mode. This is what the list-changed indicator's @@ -37,12 +44,16 @@ export interface UsePaginatedListParams<T> { managedItems: T[]; /** Re-fetch the whole aggregate (all-pages mode Refresh). */ managedRefresh: () => Promise<unknown>; + /** The aggregate store's last-fetch error (all-pages mode). */ + managedError: Error | null; /** The accumulated paged list (paginated mode display source). */ pagedItems: T[]; /** The paged store's current `nextCursor` (undefined = at the end). */ pagedNextCursor?: string; /** The paged store's page count (page 1 = 1). */ pagedPageCount: number; + /** The paged store's last page-load error (paginated mode). */ + pagedError: Error | null; /** Fetch one page; `undefined` cursor = page 1 (replaces the paged list). */ loadPage: (cursor?: string) => Promise<unknown>; } @@ -64,9 +75,11 @@ export function usePaginatedList<T>({ paginated, managedItems, managedRefresh, + managedError, pagedItems, pagedNextCursor, pagedPageCount, + pagedError, loadPage, }: UsePaginatedListParams<T>): PaginatedListModel<T> { const onLoadMore = useCallback((): Promise<unknown> => { @@ -80,6 +93,7 @@ export function usePaginatedList<T>({ return { items: paginated ? pagedItems : managedItems, + error: paginated ? pagedError : managedError, paginated, // Masked by `connected`: while disconnected there is no page to load and no // meaningful page count (the store resets on disconnect). diff --git a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts index 40584cf9..b82828b8 100644 --- a/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedPromptsState.test.ts @@ -28,6 +28,14 @@ function waitForChange(state: PagedPromptsState): Promise<Prompt[]> { }); } +function waitForError(state: PagedPromptsState): Promise<Error | null> { + return new Promise((resolve) => { + state.addEventListener("errorChange", (e) => resolve(e.detail), { + once: true, + }); + }); +} + describe("PagedPromptsState", () => { let client: FakeInspectorClient; let state: PagedPromptsState; @@ -201,4 +209,61 @@ describe("PagedPromptsState", () => { expect(client.listPrompts).not.toHaveBeenCalled(); }); }); + + // #1998: in paginated mode this store — not the managed one — drives the + // sidebar, so a failed page must surface here or the panel renders empty + // with no alert and no Retry. + describe("load errors (#1998)", () => { + it("records the failure, dispatches errorChange, and re-throws", async () => { + client.setStatus("connected"); + const boom = new Error("list failed"); + client.listPrompts.mockRejectedValueOnce(boom); + const errorPromise = waitForError(state); + await expect(state.loadPage()).rejects.toThrow("list failed"); + expect(await errorPromise).toBe(boom); + expect(state.getError()).toBe(boom); + }); + + it("wraps a non-Error rejection", async () => { + client.setStatus("connected"); + client.listPrompts.mockRejectedValueOnce("plain string"); + await expect(state.loadPage()).rejects.toBe("plain string"); + expect(state.getError()?.message).toBe("plain string"); + }); + + it("clears the error on the next successful load", async () => { + client.setStatus("connected"); + client.listPrompts.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + client.queuePromptPages({ prompts: [prompt("a")] }); + const cleared = waitForError(state); + await state.loadPage(); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("clears the error on disconnect", async () => { + client.setStatus("connected"); + client.listPrompts.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + const cleared = waitForError(state); + client.setStatus("disconnected"); + expect(await cleared).toBeNull(); + }); + + it("records a connect-time auto-load failure instead of floating it", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedPromptsState(spClient); + const boom = new Error("connect-time list failed"); + spClient.listPrompts.mockRejectedValueOnce(boom); + const errored = waitForError(spState); + spClient.dispatchTypedEvent("connect"); + expect(await errored).toBe(boom); + expect(spState.getError()).toBe(boom); + spState.destroy(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts index c60d75a3..c8fee7e7 100644 --- a/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedResourcesState.test.ts @@ -28,6 +28,14 @@ function waitForChange(state: PagedResourcesState): Promise<Resource[]> { }); } +function waitForError(state: PagedResourcesState): Promise<Error | null> { + return new Promise((resolve) => { + state.addEventListener("errorChange", (e) => resolve(e.detail), { + once: true, + }); + }); +} + describe("PagedResourcesState", () => { let client: FakeInspectorClient; let state: PagedResourcesState; @@ -206,4 +214,61 @@ describe("PagedResourcesState", () => { expect(client.listResources).not.toHaveBeenCalled(); }); }); + + // #1998: in paginated mode this store — not the managed one — drives the + // sidebar, so a failed page must surface here or the panel renders empty + // with no alert and no Retry. + describe("load errors (#1998)", () => { + it("records the failure, dispatches errorChange, and re-throws", async () => { + client.setStatus("connected"); + const boom = new Error("list failed"); + client.listResources.mockRejectedValueOnce(boom); + const errorPromise = waitForError(state); + await expect(state.loadPage()).rejects.toThrow("list failed"); + expect(await errorPromise).toBe(boom); + expect(state.getError()).toBe(boom); + }); + + it("wraps a non-Error rejection", async () => { + client.setStatus("connected"); + client.listResources.mockRejectedValueOnce("plain string"); + await expect(state.loadPage()).rejects.toBe("plain string"); + expect(state.getError()?.message).toBe("plain string"); + }); + + it("clears the error on the next successful load", async () => { + client.setStatus("connected"); + client.listResources.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + client.queueResourcePages({ resources: [resource("a://1")] }); + const cleared = waitForError(state); + await state.loadPage(); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("clears the error on disconnect", async () => { + client.setStatus("connected"); + client.listResources.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + const cleared = waitForError(state); + client.setStatus("disconnected"); + expect(await cleared).toBeNull(); + }); + + it("records a connect-time auto-load failure instead of floating it", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedResourcesState(spClient); + const boom = new Error("connect-time list failed"); + spClient.listResources.mockRejectedValueOnce(boom); + const errored = waitForError(spState); + spClient.dispatchTypedEvent("connect"); + expect(await errored).toBe(boom); + expect(spState.getError()).toBe(boom); + spState.destroy(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts index fb1509d5..b8ee12f5 100644 --- a/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts +++ b/clients/web/src/test/core/mcp/state/pagedToolsState.test.ts @@ -28,6 +28,14 @@ function waitForChange(state: PagedToolsState): Promise<Tool[]> { }); } +function waitForError(state: PagedToolsState): Promise<Error | null> { + return new Promise((resolve) => { + state.addEventListener("errorChange", (e) => resolve(e.detail), { + once: true, + }); + }); +} + describe("PagedToolsState", () => { let client: FakeInspectorClient; let state: PagedToolsState; @@ -238,4 +246,61 @@ describe("PagedToolsState", () => { expect(state.getTools()).toEqual([]); }); }); + + // #1998: in paginated mode this store — not the managed one — drives the + // sidebar, so a failed page must surface here or the panel renders empty + // with no alert and no Retry. + describe("load errors (#1998)", () => { + it("records the failure, dispatches errorChange, and re-throws", async () => { + client.setStatus("connected"); + const boom = new Error("list failed"); + client.listTools.mockRejectedValueOnce(boom); + const errorPromise = waitForError(state); + await expect(state.loadPage()).rejects.toThrow("list failed"); + expect(await errorPromise).toBe(boom); + expect(state.getError()).toBe(boom); + }); + + it("wraps a non-Error rejection", async () => { + client.setStatus("connected"); + client.listTools.mockRejectedValueOnce("plain string"); + await expect(state.loadPage()).rejects.toBe("plain string"); + expect(state.getError()?.message).toBe("plain string"); + }); + + it("clears the error on the next successful load", async () => { + client.setStatus("connected"); + client.listTools.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + client.queueToolPages({ tools: [tool("a")] }); + const cleared = waitForError(state); + await state.loadPage(); + expect(await cleared).toBeNull(); + expect(state.getError()).toBeNull(); + }); + + it("clears the error on disconnect", async () => { + client.setStatus("connected"); + client.listTools.mockRejectedValueOnce(new Error("list failed")); + await expect(state.loadPage()).rejects.toThrow(); + const cleared = waitForError(state); + client.setStatus("disconnected"); + expect(await cleared).toBeNull(); + }); + + it("records a connect-time auto-load failure instead of floating it", async () => { + const spClient = new FakeInspectorClient({ + serverSettings: PAGINATED_SETTINGS, + }); + spClient.setStatus("connected"); + const spState = new PagedToolsState(spClient); + const boom = new Error("connect-time list failed"); + spClient.listTools.mockRejectedValueOnce(boom); + const errored = waitForError(spState); + spClient.dispatchTypedEvent("connect"); + expect(await errored).toBe(boom); + expect(spState.getError()).toBe(boom); + spState.destroy(); + }); + }); }); diff --git a/clients/web/src/test/core/react/useManagedListError.test.tsx b/clients/web/src/test/core/react/useListError.test.tsx similarity index 84% rename from clients/web/src/test/core/react/useManagedListError.test.tsx rename to clients/web/src/test/core/react/useListError.test.tsx index 1eac0afd..4d7c34c1 100644 --- a/clients/web/src/test/core/react/useManagedListError.test.tsx +++ b/clients/web/src/test/core/react/useListError.test.tsx @@ -2,12 +2,12 @@ import { describe, it, expect, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { FakeInspectorClient } from "@inspector/core/mcp/__tests__/fakeInspectorClient"; import { ManagedToolsState } from "@inspector/core/mcp/state/managedToolsState"; -import { useManagedListError } from "@inspector/core/react/useManagedListError"; +import { useListError } from "@inspector/core/react/useListError"; // The shared subscription behind the four `useManaged*` hooks' `error` field // (#1953). Exercised through ManagedToolsState — any managed list would do, // since the error lives entirely in the shared base. -describe("useManagedListError", () => { +describe("useListError", () => { let client: FakeInspectorClient; let state: ManagedToolsState; const boom = new Error("Invalid result for tools/list: ttlMs required"); @@ -21,12 +21,12 @@ describe("useManagedListError", () => { }); it("returns null when there is no state", () => { - const { result } = renderHook(() => useManagedListError(null)); + const { result } = renderHook(() => useListError(null)); expect(result.current).toBeNull(); }); it("returns null before any load fails", () => { - const { result } = renderHook(() => useManagedListError(state)); + const { result } = renderHook(() => useListError(state)); expect(result.current).toBeNull(); }); @@ -34,12 +34,12 @@ describe("useManagedListError", () => { client.listAllTools.mockRejectedValueOnce(boom); await expect(state.refresh()).rejects.toThrow(boom); - const { result } = renderHook(() => useManagedListError(state)); + const { result } = renderHook(() => useListError(state)); expect(result.current).toBe(boom); }); it("updates when the state dispatches errorChange", async () => { - const { result } = renderHook(() => useManagedListError(state)); + const { result } = renderHook(() => useListError(state)); client.listAllTools.mockRejectedValueOnce(boom); await act(async () => { @@ -51,7 +51,7 @@ describe("useManagedListError", () => { it("clears when a later load succeeds", async () => { client.listAllTools.mockRejectedValueOnce(boom); await expect(state.refresh()).rejects.toThrow(boom); - const { result } = renderHook(() => useManagedListError(state)); + const { result } = renderHook(() => useListError(state)); expect(result.current).toBe(boom); await act(async () => { @@ -65,7 +65,7 @@ describe("useManagedListError", () => { await expect(state.refresh()).rejects.toThrow(boom); const { result, rerender } = renderHook( - ({ s }: { s: ManagedToolsState | null }) => useManagedListError(s), + ({ s }: { s: ManagedToolsState | null }) => useListError(s), { initialProps: { s: state as ManagedToolsState | null } }, ); expect(result.current).toBe(boom); @@ -75,7 +75,7 @@ describe("useManagedListError", () => { }); it("unsubscribes on unmount", async () => { - const { unmount } = renderHook(() => useManagedListError(state)); + const { unmount } = renderHook(() => useListError(state)); unmount(); client.listAllTools.mockRejectedValueOnce(boom); @@ -98,7 +98,7 @@ describe("useManagedListError", () => { const other = new ManagedToolsState(client, 0); const { result, rerender } = renderHook( - ({ s }: { s: ManagedToolsState }) => useManagedListError(s), + ({ s }: { s: ManagedToolsState }) => useListError(s), { initialProps: { s: state } }, ); expect(result.current).toBe(boom); diff --git a/clients/web/src/test/core/react/usePagedPrompts.test.tsx b/clients/web/src/test/core/react/usePagedPrompts.test.tsx index 1a3bca4a..c50283c9 100644 --- a/clients/web/src/test/core/react/usePagedPrompts.test.tsx +++ b/clients/web/src/test/core/react/usePagedPrompts.test.tsx @@ -118,4 +118,31 @@ describe("usePagedPrompts", () => { await state.loadPage(); expect(result.current.prompts).toEqual([]); }); + + // #1998: paginated mode renders this store, so its load failure is what the + // panel's alert + Retry key off. + it("exposes the state's last load error and clears it on success", async () => { + const { result } = renderHook(() => usePagedPrompts(client, state)); + expect(result.current.error).toBeNull(); + const boom = new Error("list failed"); + client.listPrompts.mockRejectedValueOnce(boom); + await act(async () => { + await expect(result.current.loadPage()).rejects.toThrow("list failed"); + }); + await waitFor(() => { + expect(result.current.error).toBe(boom); + }); + client.queuePromptPages({ prompts: [prompt("a")] }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it("reports no error when state is null", () => { + const { result } = renderHook(() => usePagedPrompts(client, null)); + expect(result.current.error).toBeNull(); + }); }); diff --git a/clients/web/src/test/core/react/usePagedResources.test.tsx b/clients/web/src/test/core/react/usePagedResources.test.tsx index b5b6be44..9ee0dd2d 100644 --- a/clients/web/src/test/core/react/usePagedResources.test.tsx +++ b/clients/web/src/test/core/react/usePagedResources.test.tsx @@ -125,4 +125,31 @@ describe("usePagedResources", () => { await state.loadPage(); expect(result.current.resources).toEqual([]); }); + + // #1998: paginated mode renders this store, so its load failure is what the + // panel's alert + Retry key off. + it("exposes the state's last load error and clears it on success", async () => { + const { result } = renderHook(() => usePagedResources(client, state)); + expect(result.current.error).toBeNull(); + const boom = new Error("list failed"); + client.listResources.mockRejectedValueOnce(boom); + await act(async () => { + await expect(result.current.loadPage()).rejects.toThrow("list failed"); + }); + await waitFor(() => { + expect(result.current.error).toBe(boom); + }); + client.queueResourcePages({ resources: [resource("a://1")] }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it("reports no error when state is null", () => { + const { result } = renderHook(() => usePagedResources(client, null)); + expect(result.current.error).toBeNull(); + }); }); diff --git a/clients/web/src/test/core/react/usePagedTools.test.tsx b/clients/web/src/test/core/react/usePagedTools.test.tsx index c5bc5f85..f6342de0 100644 --- a/clients/web/src/test/core/react/usePagedTools.test.tsx +++ b/clients/web/src/test/core/react/usePagedTools.test.tsx @@ -125,4 +125,31 @@ describe("usePagedTools", () => { await state.loadPage(); expect(result.current.tools).toEqual([]); }); + + // #1998: paginated mode renders this store, so its load failure is what the + // panel's alert + Retry key off. + it("exposes the state's last load error and clears it on success", async () => { + const { result } = renderHook(() => usePagedTools(client, state)); + expect(result.current.error).toBeNull(); + const boom = new Error("list failed"); + client.listTools.mockRejectedValueOnce(boom); + await act(async () => { + await expect(result.current.loadPage()).rejects.toThrow("list failed"); + }); + await waitFor(() => { + expect(result.current.error).toBe(boom); + }); + client.queueToolPages({ tools: [tool("a")] }); + await act(async () => { + await result.current.loadPage(); + }); + await waitFor(() => { + expect(result.current.error).toBeNull(); + }); + }); + + it("reports no error when state is null", () => { + const { result } = renderHook(() => usePagedTools(client, null)); + expect(result.current.error).toBeNull(); + }); }); diff --git a/core/mcp/state/pagedPromptsState.ts b/core/mcp/state/pagedPromptsState.ts index ed649711..80fbe6ca 100644 --- a/core/mcp/state/pagedPromptsState.ts +++ b/core/mcp/state/pagedPromptsState.ts @@ -21,6 +21,8 @@ import type { PagePaginationState } from "./pagedToolsState.js"; export interface PagedPromptsStateEventMap { promptsChange: Prompt[]; paginationChange: PagePaginationState; + /** The last page load's failure, or `null` once a load succeeds. */ + errorChange: Error | null; } export interface LoadPageResult { @@ -35,6 +37,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa // Double-click guard: a load in flight makes the next `loadPage` a no-op so // the same page can't be appended twice (#1721). private loading = false; + // The last page load's failure, kept as observable state so a failing page + // renders an alert with a Retry instead of an empty sidebar. The aggregate + // stores carry the same state, but they are not the display source in + // paginated mode, so their error never fires there (#1998). + private error: Error | null = null; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; @@ -43,7 +50,11 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa this.client = client; const onConnect = (): void => { if (this.client?.getServerSettings()?.paginatedLists) { - void this.loadPage(undefined); + // No caller to await this one, so its rejection is caught here rather + // than left to become an unhandled rejection. Not a swallow: + // `loadPage` has already recorded the failure via `setError`, and the + // list panel renders it (#1998). + void this.loadPage(undefined).catch(() => {}); } }; const onStatusChange = (): void => { @@ -70,6 +81,20 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa return { nextCursor: this.nextCursor, pageCount: this.pageCount }; } + /** The last page load's failure, or `null` when the last load succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.dispatchTypedEvent("errorChange", value); + } + clear(): void { this.reset(); } @@ -80,6 +105,9 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa this.pageCount = 0; this.dispatchTypedEvent("promptsChange", this.prompts); this.dispatchTypedEvent("paginationChange", this.getPagination()); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load" must not outlive it into the next connect. + this.setError(null); } async loadPage( @@ -102,9 +130,17 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa : [...this.prompts, ...result.prompts]; this.pageCount = cursor === undefined ? 1 : this.pageCount + 1; this.nextCursor = result.nextCursor; + this.setError(null); this.dispatchTypedEvent("promptsChange", this.prompts); this.dispatchTypedEvent("paginationChange", this.getPagination()); return { prompts: result.prompts, nextCursor: result.nextCursor }; + } catch (err) { + // Recorded as observable state AND re-thrown: the state drives the + // panel's alert, while the rejection is what a caller's auth-recovery + // wrapper keys off to detect a 401 and start a re-authorization. The + // connect-time load, which has no such caller, catches it above. + this.setError(err instanceof Error ? err : new Error(String(err))); + throw err; } finally { this.loading = false; } @@ -116,5 +152,6 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa this.prompts = []; this.nextCursor = undefined; this.pageCount = 0; + this.error = null; } } diff --git a/core/mcp/state/pagedResourcesState.ts b/core/mcp/state/pagedResourcesState.ts index b5e038d4..bd5194ac 100644 --- a/core/mcp/state/pagedResourcesState.ts +++ b/core/mcp/state/pagedResourcesState.ts @@ -22,6 +22,8 @@ import type { PagePaginationState } from "./pagedToolsState.js"; export interface PagedResourcesStateEventMap { resourcesChange: Resource[]; paginationChange: PagePaginationState; + /** The last page load's failure, or `null` once a load succeeds. */ + errorChange: Error | null; } export interface LoadPageResult { @@ -36,6 +38,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve // Double-click guard: a load in flight makes the next `loadPage` a no-op so // the same page can't be appended twice (#1721). private loading = false; + // The last page load's failure, kept as observable state so a failing page + // renders an alert with a Retry instead of an empty sidebar. The aggregate + // stores carry the same state, but they are not the display source in + // paginated mode, so their error never fires there (#1998). + private error: Error | null = null; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; @@ -44,7 +51,11 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve this.client = client; const onConnect = (): void => { if (this.client?.getServerSettings()?.paginatedLists) { - void this.loadPage(undefined); + // No caller to await this one, so its rejection is caught here rather + // than left to become an unhandled rejection. Not a swallow: + // `loadPage` has already recorded the failure via `setError`, and the + // list panel renders it (#1998). + void this.loadPage(undefined).catch(() => {}); } }; const onStatusChange = (): void => { @@ -71,6 +82,20 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve return { nextCursor: this.nextCursor, pageCount: this.pageCount }; } + /** The last page load's failure, or `null` when the last load succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.dispatchTypedEvent("errorChange", value); + } + clear(): void { this.reset(); } @@ -81,6 +106,9 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve this.pageCount = 0; this.dispatchTypedEvent("resourcesChange", this.resources); this.dispatchTypedEvent("paginationChange", this.getPagination()); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load" must not outlive it into the next connect. + this.setError(null); } async loadPage( @@ -103,9 +131,17 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve : [...this.resources, ...result.resources]; this.pageCount = cursor === undefined ? 1 : this.pageCount + 1; this.nextCursor = result.nextCursor; + this.setError(null); this.dispatchTypedEvent("resourcesChange", this.resources); this.dispatchTypedEvent("paginationChange", this.getPagination()); return { resources: result.resources, nextCursor: result.nextCursor }; + } catch (err) { + // Recorded as observable state AND re-thrown: the state drives the + // panel's alert, while the rejection is what a caller's auth-recovery + // wrapper keys off to detect a 401 and start a re-authorization. The + // connect-time load, which has no such caller, catches it above. + this.setError(err instanceof Error ? err : new Error(String(err))); + throw err; } finally { this.loading = false; } @@ -117,5 +153,6 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve this.resources = []; this.nextCursor = undefined; this.pageCount = 0; + this.error = null; } } diff --git a/core/mcp/state/pagedToolsState.ts b/core/mcp/state/pagedToolsState.ts index dca42f71..63313489 100644 --- a/core/mcp/state/pagedToolsState.ts +++ b/core/mcp/state/pagedToolsState.ts @@ -30,6 +30,8 @@ export interface PagePaginationState { export interface PagedToolsStateEventMap { toolsChange: Tool[]; paginationChange: PagePaginationState; + /** The last page load's failure, or `null` once a load succeeds. */ + errorChange: Error | null; } export interface LoadPageResult { @@ -45,6 +47,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { // page"): both calls would otherwise read the same cursor and append the // same page twice. A load in flight makes the next `loadPage` a no-op (#1721). private loading = false; + // The last page load's failure, kept as observable state so a failing page + // renders an alert with a Retry instead of an empty sidebar. The aggregate + // stores carry the same state, but they are not the display source in + // paginated mode, so their error never fires there (#1998). + private error: Error | null = null; private client: InspectorClientProtocol | null = null; private unsubscribe: (() => void) | null = null; @@ -55,7 +62,11 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { // Auto-load page 1 only in paginated mode — otherwise the managed // (aggregate) state is the display source and this stays idle (#1721). if (this.client?.getServerSettings()?.paginatedLists) { - void this.loadPage(undefined); + // No caller to await this one, so its rejection is caught here rather + // than left to become an unhandled rejection. Not a swallow: + // `loadPage` has already recorded the failure via `setError`, and the + // list panel renders it (#1998). + void this.loadPage(undefined).catch(() => {}); } }; const onStatusChange = (): void => { @@ -82,6 +93,20 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { return { nextCursor: this.nextCursor, pageCount: this.pageCount }; } + /** The last page load's failure, or `null` when the last load succeeded. */ + getError(): Error | null { + return this.error; + } + + // Compared by identity rather than message: two distinct failures with the + // same text are still two events, and a re-render on a repeat failure is + // cheap next to silently coalescing them. + private setError(value: Error | null): void { + if (this.error === value) return; + this.error = value; + this.dispatchTypedEvent("errorChange", value); + } + /** Clear the accumulated list and pagination progress. */ clear(): void { this.reset(); @@ -93,6 +118,9 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { this.pageCount = 0; this.dispatchTypedEvent("toolsChange", this.tools); this.dispatchTypedEvent("paginationChange", this.getPagination()); + // A disconnect ends the session the error belonged to — a stale + // "couldn't load" must not outlive it into the next connect. + this.setError(null); } async loadPage(cursor?: string): Promise<LoadPageResult> { @@ -116,9 +144,17 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { : [...this.tools, ...result.tools]; this.pageCount = cursor === undefined ? 1 : this.pageCount + 1; this.nextCursor = result.nextCursor; + this.setError(null); this.dispatchTypedEvent("toolsChange", this.tools); this.dispatchTypedEvent("paginationChange", this.getPagination()); return { tools: result.tools, nextCursor: result.nextCursor }; + } catch (err) { + // Recorded as observable state AND re-thrown: the state drives the + // panel's alert, while the rejection is what a caller's auth-recovery + // wrapper keys off to detect a 401 and start a re-authorization. The + // connect-time load, which has no such caller, catches it above. + this.setError(err instanceof Error ? err : new Error(String(err))); + throw err; } finally { this.loading = false; } @@ -130,5 +166,6 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> { this.tools = []; this.nextCursor = undefined; this.pageCount = 0; + this.error = null; } } diff --git a/core/react/useManagedListError.ts b/core/react/useListError.ts similarity index 58% rename from core/react/useManagedListError.ts rename to core/react/useListError.ts index 64438e52..01da48db 100644 --- a/core/react/useManagedListError.ts +++ b/core/react/useListError.ts @@ -1,35 +1,46 @@ import { useCallback, useSyncExternalStore } from "react"; -import type { ManagedListEventMap } from "../mcp/state/managedListState.js"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; /** - * The slice of a managed list state this hook needs. Declared structurally - * rather than as `ManagedListState<T, M>` so the four list hooks can share it - * without threading their item type through — the error is the same shape for - * all of them. + * The single event both list-state families expose for this. Declared here + * rather than imported from either so the hook depends on neither: the managed + * (aggregate) states and the paged states each carry an `errorChange` of this + * shape, and this is the whole contract the hook needs. */ -export interface ManagedListErrorSource { +interface ListErrorEventMap { + errorChange: Error | null; +} + +/** + * The slice of a list state this hook needs. Declared structurally rather than + * as a concrete state class so every list hook can share it without threading + * its item type through — the error is the same shape for all of them. + */ +export interface ListErrorSource { getError(): Error | null; addEventListener( type: "errorChange", listener: ( - event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + event: TypedEventGeneric<ListErrorEventMap, "errorChange">, ) => void, ): void; removeEventListener( type: "errorChange", listener: ( - event: TypedEventGeneric<ManagedListEventMap, "errorChange">, + event: TypedEventGeneric<ListErrorEventMap, "errorChange">, ) => void, ): void; } /** - * Subscribe to a managed list state's last-fetch error (#1953). + * Subscribe to a list state's last-fetch error (#1953, #1998). * - * Shared by the four `useManaged*` hooks so a list load that fails — including - * the connect-time one, which has no caller to await it — reaches the UI - * instead of only the console. `null` means the last fetch succeeded. + * Shared by the four `useManaged*` hooks and the three `usePaged*` hooks so a + * list load that fails — including the connect-time one, which has no caller + * to await it — reaches the UI instead of only the console. `null` means the + * last fetch succeeded. Both families need it because the paged stores are the + * display source in paginated mode, where the managed stores deliberately + * never fetch (#1998). * * Built on `useSyncExternalStore` rather than the `useState` + `useEffect` * subscribe pattern the sibling hooks use. Re-syncing state from the `state` @@ -44,9 +55,7 @@ export interface ManagedListErrorSource { * which it is: it returns the stored `Error` instance itself (or `null`), never * a fresh object. */ -export function useManagedListError( - state: ManagedListErrorSource | null, -): Error | null { +export function useListError(state: ListErrorSource | null): Error | null { const subscribe = useCallback( (onStoreChange: () => void) => { if (!state) return () => {}; diff --git a/core/react/useManagedPrompts.ts b/core/react/useManagedPrompts.ts index e0de0bb1..8a14d0df 100644 --- a/core/react/useManagedPrompts.ts +++ b/core/react/useManagedPrompts.ts @@ -6,7 +6,7 @@ import type { } from "../mcp/state/managedPromptsState.js"; import type { Prompt } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; -import { useManagedListError } from "./useManagedListError.js"; +import { useListError } from "./useListError.js"; export interface UseManagedPromptsResult { /** @@ -72,7 +72,7 @@ export function useManagedPrompts( }; }, [managedPromptsState]); - const error = useManagedListError(managedPromptsState); + const error = useListError(managedPromptsState); const refresh = useCallback(async (): Promise<Prompt[]> => { if (!managedPromptsState || !client) return []; diff --git a/core/react/useManagedResourceTemplates.ts b/core/react/useManagedResourceTemplates.ts index 40a6b994..61a4c382 100644 --- a/core/react/useManagedResourceTemplates.ts +++ b/core/react/useManagedResourceTemplates.ts @@ -6,7 +6,7 @@ import type { } from "../mcp/state/managedResourceTemplatesState.js"; import type { ResourceTemplateType as ResourceTemplate } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; -import { useManagedListError } from "./useManagedListError.js"; +import { useListError } from "./useListError.js"; export interface UseManagedResourceTemplatesResult { /** @@ -57,7 +57,7 @@ export function useManagedResourceTemplates( }; }, [managedResourceTemplatesState]); - const error = useManagedListError(managedResourceTemplatesState); + const error = useListError(managedResourceTemplatesState); const refresh = useCallback(async (): Promise<ResourceTemplate[]> => { if (!managedResourceTemplatesState || !client) return []; diff --git a/core/react/useManagedResources.ts b/core/react/useManagedResources.ts index 12786062..ad9b1eb5 100644 --- a/core/react/useManagedResources.ts +++ b/core/react/useManagedResources.ts @@ -6,7 +6,7 @@ import type { } from "../mcp/state/managedResourcesState.js"; import type { Resource } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; -import { useManagedListError } from "./useManagedListError.js"; +import { useListError } from "./useListError.js"; export interface UseManagedResourcesResult { /** @@ -83,7 +83,7 @@ export function useManagedResources( }; }, [managedResourcesState]); - const error = useManagedListError(managedResourcesState); + const error = useListError(managedResourcesState); const refresh = useCallback(async (): Promise<Resource[]> => { if (!managedResourcesState || !client) return []; diff --git a/core/react/useManagedTools.ts b/core/react/useManagedTools.ts index d7ed1c22..3b805038 100644 --- a/core/react/useManagedTools.ts +++ b/core/react/useManagedTools.ts @@ -4,7 +4,7 @@ import type { ManagedToolsState } from "../mcp/state/managedToolsState.js"; import type { ManagedToolsStateEventMap } from "../mcp/state/managedToolsState.js"; import type { Tool } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; -import { useManagedListError } from "./useManagedListError.js"; +import { useListError } from "./useListError.js"; export interface UseManagedToolsResult { /** @@ -72,7 +72,7 @@ export function useManagedTools( }; }, [managedToolsState]); - const error = useManagedListError(managedToolsState); + const error = useListError(managedToolsState); const refresh = useCallback(async (): Promise<Tool[]> => { if (!managedToolsState || !client) return []; diff --git a/core/react/usePagedPrompts.ts b/core/react/usePagedPrompts.ts index 94650c42..3e3efd44 100644 --- a/core/react/usePagedPrompts.ts +++ b/core/react/usePagedPrompts.ts @@ -7,6 +7,7 @@ import type { } from "../mcp/state/pagedPromptsState.js"; import type { Prompt } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useListError } from "./useListError.js"; export interface UsePagedPromptsResult { prompts: Prompt[]; @@ -14,6 +15,12 @@ export interface UsePagedPromptsResult { nextCursor?: string; /** Pages loaded since the last reset (page 1 = 1). */ pageCount: number; + /** + * The last page load's failure, or `null` when it succeeded. In paginated + * mode this store is the display source, so this — not the managed + * store's error — is what the panel renders (#1998). + */ + error: Error | null; loadPage: ( cursor?: string, metadata?: Record<string, string>, @@ -72,6 +79,8 @@ export function usePagedPrompts( }; }, [pagedPromptsState]); + const error = useListError(pagedPromptsState); + const loadPage = useCallback( async ( cursor?: string, @@ -89,5 +98,5 @@ export function usePagedPrompts( pagedPromptsState?.clear(); }, [pagedPromptsState]); - return { prompts, nextCursor, pageCount, loadPage, clear }; + return { prompts, nextCursor, pageCount, error, loadPage, clear }; } diff --git a/core/react/usePagedResources.ts b/core/react/usePagedResources.ts index 25336a5d..3e5e3f8a 100644 --- a/core/react/usePagedResources.ts +++ b/core/react/usePagedResources.ts @@ -7,6 +7,7 @@ import type { } from "../mcp/state/pagedResourcesState.js"; import type { Resource } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useListError } from "./useListError.js"; export interface UsePagedResourcesResult { resources: Resource[]; @@ -14,6 +15,12 @@ export interface UsePagedResourcesResult { nextCursor?: string; /** Pages loaded since the last reset (page 1 = 1). */ pageCount: number; + /** + * The last page load's failure, or `null` when it succeeded. In paginated + * mode this store is the display source, so this — not the managed + * store's error — is what the panel renders (#1998). + */ + error: Error | null; loadPage: ( cursor?: string, metadata?: Record<string, string>, @@ -78,6 +85,8 @@ export function usePagedResources( }; }, [pagedResourcesState]); + const error = useListError(pagedResourcesState); + const loadPage = useCallback( async ( cursor?: string, @@ -95,5 +104,5 @@ export function usePagedResources( pagedResourcesState?.clear(); }, [pagedResourcesState]); - return { resources, nextCursor, pageCount, loadPage, clear }; + return { resources, nextCursor, pageCount, error, loadPage, clear }; } diff --git a/core/react/usePagedTools.ts b/core/react/usePagedTools.ts index 026b5831..844a862d 100644 --- a/core/react/usePagedTools.ts +++ b/core/react/usePagedTools.ts @@ -7,6 +7,7 @@ import type { } from "../mcp/state/pagedToolsState.js"; import type { Tool } from "@modelcontextprotocol/client"; import type { TypedEventGeneric } from "../mcp/typedEventTarget.js"; +import { useListError } from "./useListError.js"; export interface UsePagedToolsResult { tools: Tool[]; @@ -14,6 +15,12 @@ export interface UsePagedToolsResult { nextCursor?: string; /** Pages loaded since the last reset (page 1 = 1). */ pageCount: number; + /** + * The last page load's failure, or `null` when it succeeded. In paginated + * mode this store is the display source, so this — not the managed + * store's error — is what the panel renders (#1998). + */ + error: Error | null; loadPage: (cursor?: string) => Promise<LoadPageResult>; clear: () => void; } @@ -68,6 +75,8 @@ export function usePagedTools( }; }, [pagedToolsState]); + const error = useListError(pagedToolsState); + const loadPage = useCallback( async (cursor?: string): Promise<LoadPageResult> => { if (!pagedToolsState || !client) { @@ -82,5 +91,5 @@ export function usePagedTools( pagedToolsState?.clear(); }, [pagedToolsState]); - return { tools, nextCursor, pageCount, loadPage, clear }; + return { tools, nextCursor, pageCount, error, loadPage, clear }; } From f96c4583ee0d153453d2ddda3500ef6781f6164a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 18 Aug 2026 10:47:33 -0400 Subject: [PATCH 224/941] fix: handle background command rejections in App (#2049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runWithCommandAuthRecovery` rethrows anything that isn't an `AuthRecoveryRequiredError`, and 17 of its 18 call sites were bare `void runWithCommandAuthRecovery(...)` — so any non-auth failure of a user-initiated list reload (Retry, Load next page, the pagination-mode toggle) became an unhandled rejection in the browser. Neither gate that should catch it could: `void` is the sanctioned suppression for `no-floating-promises`, and `smoke:web:browser` — which hard-fails on exactly this signal — never connects to a server. Add `runCommandInBackground`, a fire-and-forget wrapper that owns the rejection, and route every background command through it, so a new call site cannot reintroduce the gap by omission. An optional `errorTitle` picks the reporting: omitted where the list panel already renders the failure with a Retry (a toast would duplicate it), passed where nothing else records it — `logging/setLevel` and the tasks refresh. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.test.tsx | 153 +++++++++++++++++++++++++++++++++++ clients/web/src/App.tsx | 139 ++++++++++++++++--------------- 2 files changed, 227 insertions(+), 65 deletions(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index f9b2b921..09d1e67e 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -579,6 +579,8 @@ import { import * as McpIndex from "@inspector/core/mcp/index.js"; import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js"; import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; +import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; +import { usePagedTools } from "@inspector/core/react/usePagedTools.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; @@ -2422,3 +2424,154 @@ describe("App paginated list pagination toggle (#1721)", () => { expect(lastPush?.paginatedLists).toBeFalsy(); }); }); + +// A background command runs through `runCommandInBackground`, which owns the +// rejection. Before #2049 the 17 `void runWithCommandAuthRecovery(...)` sites +// had no handler, so any non-auth failure of the wrapped operation became an +// unhandled rejection in the browser — invisible to the type-aware +// no-floating-promises rule (`void` is its sanctioned suppression) and fatal to +// `smoke:web:browser`, which hard-fails on exactly that signal. +describe("App background command rejections (#2049)", () => { + const LIST_FAILURE = new Error("list boom"); + + /** + * Record process-level unhandled rejections for the duration of a test. + * Asserting on the captured list — rather than relying on vitest failing the + * whole run — is what attributes a regression to this test rather than to + * whichever file happened to be running when the rejection surfaced. + */ + const captureUnhandledRejections = () => { + const seen: unknown[] = []; + const onUnhandled = (reason: unknown) => { + seen.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + return { + seen, + stop: () => { + process.off("unhandledRejection", onUnhandled); + }, + }; + }; + + // Node reports an unhandled rejection at the end of the turn, so drain the + // macrotask queue before asserting that none was reported. + const settleRejections = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }; + + beforeEach(() => { + clientInstances.length = 0; + notificationsMock.show.mockClear(); + }); + + afterEach(() => { + // Restore the module defaults the other blocks render against. + vi.mocked(useManagedTools).mockReturnValue({ + tools: [{ name: "get_acts", inputSchema: { type: "object" } }], + error: null, + listChanged: false, + refresh: vi.fn().mockResolvedValue([]), + clearListChanged: clearToolsListChangedSpy, + }); + vi.mocked(usePagedTools).mockReturnValue({ + tools: [], + nextCursor: undefined, + pageCount: 0, + error: null, + loadPage: vi.fn(() => + Promise.resolve({ tools: [], nextCursor: undefined }), + ), + clear: vi.fn(), + }); + }); + + const connect = async () => { + const user = userEvent.setup(); + renderWithMantine(<App />); + await user.click(screen.getByText("connect")); + await waitFor(() => expect(clientInstances).toHaveLength(1)); + return user; + }; + + it("does not leak an unhandled rejection when an all-pages Refresh fails", async () => { + vi.mocked(useManagedTools).mockReturnValue({ + tools: [], + error: LIST_FAILURE, + listChanged: false, + refresh: vi.fn().mockRejectedValue(LIST_FAILURE), + clearListChanged: clearToolsListChangedSpy, + }); + const rejections = captureUnhandledRejections(); + try { + const user = await connect(); + await user.click(screen.getByText("refresh-tools")); + await settleRejections(); + expect(rejections.seen).toEqual([]); + } finally { + rejections.stop(); + } + // The panel already renders the failure with a Retry, so no toast is added + // on top of it — that is the whole reason this site swallows the rejection. + expect(notificationsMock.show).not.toHaveBeenCalled(); + }); + + it("does not leak an unhandled rejection when a paginated Refresh or Load-next-page fails", async () => { + vi.mocked(usePagedTools).mockReturnValue({ + tools: [], + // A cursor is what makes Load-next-page actually fetch. + nextCursor: "cursor-1", + pageCount: 1, + error: LIST_FAILURE, + loadPage: vi.fn().mockRejectedValue(LIST_FAILURE), + clear: vi.fn(), + }); + const rejections = captureUnhandledRejections(); + try { + const user = await connect(); + // Flipping the mode drives its own page-1 load, which fails too. + await user.click(screen.getByText("paginated-on")); + await waitFor(() => + expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"), + ); + await user.click(screen.getByText("refresh-tools")); + await user.click(screen.getByText("load-more-tools")); + await settleRejections(); + expect(rejections.seen).toEqual([]); + } finally { + rejections.stop(); + } + }); + + it("toasts a set-log-level failure instead of leaking it", async () => { + const rejections = captureUnhandledRejections(); + try { + const user = await connect(); + const client = clientInstances[0] as unknown as { + setLoggingLevel: ReturnType<typeof vi.fn>; + }; + client.setLoggingLevel.mockRejectedValueOnce(new Error("no logging")); + + await user.click(screen.getByText("set-level")); + + await waitFor(() => + expect(notificationsMock.show).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Failed to set log level", + message: "no logging", + color: "red", + }), + ), + ); + await settleRejections(); + expect(rejections.seen).toEqual([]); + } finally { + rejections.stop(); + } + }); +}); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 3738044d..db29e06b 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2066,6 +2066,46 @@ function App() { [inspectorClient, activeServerId, handleCommandScopedAuthRecovery], ); + /** + * Fire-and-forget form of {@link runWithCommandAuthRecovery}, for a command + * whose caller has nothing to await (a click handler, a mode toggle). + * + * The wrapper rethrows anything that is not an `AuthRecoveryRequiredError`, + * so a bare `void runWithCommandAuthRecovery(...)` turns every non-auth + * failure — a transport error, a rejected `tools/list` — into an unhandled + * rejection in the browser (#2049). Routing every background command through + * here is what keeps a new call site from reintroducing that gap by + * omission: there is no `void` to forget. + * + * `errorTitle` picks the reporting, and the choice is per call site: + * + * - **Omit it** when the operation's own state already renders the failure — + * the list loads, whose managed/paged stores record the error and whose + * panel shows it with a Retry. A toast there would only duplicate what the + * user is already looking at, so the rejection is swallowed deliberately. + * - **Pass one** when nothing else records the failure, and the user would + * otherwise see the command silently do nothing. + */ + const runCommandInBackground = useCallback( + ( + operation: () => Promise<unknown>, + source: StepUpSource, + errorTitle?: string, + ): void => { + void runWithCommandAuthRecovery(operation, source).catch( + (err: unknown) => { + if (!errorTitle) return; + notifications.show({ + title: errorTitle, + message: err instanceof Error ? err.message : String(err), + color: "red", + }); + }, + ); + }, + [runWithCommandAuthRecovery], + ); + const resumePendingReauth = useCallback( async (pending: PendingReauth) => { if (reauthResumeInProgressRef.current) { @@ -3474,12 +3514,16 @@ function App() { (level: LoggingLevel) => { setCurrentLogLevel(level); if (!inspectorClient) return; - void runWithCommandAuthRecovery( + // Nothing else records a `logging/setLevel` failure, and the optimistic + // level above already moved — so report it, or the selector would sit on + // a level the server never accepted with no explanation. + runCommandInBackground( () => inspectorClient.setLoggingLevel(level), "ambient", + "Failed to set log level", ); }, - [inspectorClient, runWithCommandAuthRecovery], + [inspectorClient, runCommandInBackground], ); // Modern era (#1629): no request is sent — the client stores the level and @@ -3502,51 +3546,39 @@ function App() { // (the managed state lit it on `list_changed`; nothing else clears it in // paginated mode) (#1721). clearToolsListChanged(); - void runWithCommandAuthRecovery( - () => toolsPagination.onRefresh(), - "ambient", - ); + runCommandInBackground(() => toolsPagination.onRefresh(), "ambient"); } else { - void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); + runCommandInBackground(() => refreshTools(), "ambient"); } }, [ paginatedLists, toolsPagination, refreshTools, clearToolsListChanged, - runWithCommandAuthRecovery, + runCommandInBackground, ]); const onRefreshPrompts = useCallback(() => { if (paginatedLists) { clearPromptsListChanged(); - void runWithCommandAuthRecovery( - () => promptsPagination.onRefresh(), - "ambient", - ); + runCommandInBackground(() => promptsPagination.onRefresh(), "ambient"); } else { - void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); + runCommandInBackground(() => refreshPrompts(), "ambient"); } }, [ paginatedLists, promptsPagination, refreshPrompts, clearPromptsListChanged, - runWithCommandAuthRecovery, + runCommandInBackground, ]); const onRefreshResources = useCallback(() => { if (paginatedLists) { clearResourcesListChanged(); - void runWithCommandAuthRecovery( - () => resourcesPagination.onRefresh(), - "ambient", - ); + runCommandInBackground(() => resourcesPagination.onRefresh(), "ambient"); // Resource templates always use the managed (aggregate) path. - void runWithCommandAuthRecovery( - () => refreshResourceTemplates(), - "ambient", - ); + runCommandInBackground(() => refreshResourceTemplates(), "ambient"); } else { - void runWithCommandAuthRecovery(async () => { + runCommandInBackground(async () => { await refreshResources(); await refreshResourceTemplates(); }, "ambient"); @@ -3557,7 +3589,7 @@ function App() { refreshResources, refreshResourceTemplates, clearResourcesListChanged, - runWithCommandAuthRecovery, + runCommandInBackground, ]); // The per-list sidebar toggle edits the server-wide `paginatedLists` setting: // optimistic override for an instant flip, live push so the managed state's @@ -3581,22 +3613,13 @@ function App() { // Wrap in ambient auth recovery so a mid-session 401 triggers re-auth // rather than surfacing raw, matching the all-pages refresh path. if (value) { - void runWithCommandAuthRecovery( - () => loadToolsPage(undefined), - "ambient", - ); - void runWithCommandAuthRecovery( - () => loadPromptsPage(undefined), - "ambient", - ); - void runWithCommandAuthRecovery( - () => loadResourcesPage(undefined), - "ambient", - ); + runCommandInBackground(() => loadToolsPage(undefined), "ambient"); + runCommandInBackground(() => loadPromptsPage(undefined), "ambient"); + runCommandInBackground(() => loadResourcesPage(undefined), "ambient"); } else { - void runWithCommandAuthRecovery(() => refreshTools(), "ambient"); - void runWithCommandAuthRecovery(() => refreshPrompts(), "ambient"); - void runWithCommandAuthRecovery(() => refreshResources(), "ambient"); + runCommandInBackground(() => refreshTools(), "ambient"); + runCommandInBackground(() => refreshPrompts(), "ambient"); + runCommandInBackground(() => refreshResources(), "ambient"); } } void updateServerSettings(activeServerId, next).catch((err: unknown) => { @@ -3625,34 +3648,24 @@ function App() { refreshTools, refreshPrompts, refreshResources, - runWithCommandAuthRecovery, + runCommandInBackground, ], ); // Wrap Load-next-page in ambient auth recovery too, so a paginated // paginated fetch that hits a 401 recovers like the all-pages path (#1721). const onLoadMoreTools = useCallback( - () => - void runWithCommandAuthRecovery( - () => toolsPagination.onLoadMore(), - "ambient", - ), - [toolsPagination, runWithCommandAuthRecovery], + () => runCommandInBackground(() => toolsPagination.onLoadMore(), "ambient"), + [toolsPagination, runCommandInBackground], ); const onLoadMorePrompts = useCallback( () => - void runWithCommandAuthRecovery( - () => promptsPagination.onLoadMore(), - "ambient", - ), - [promptsPagination, runWithCommandAuthRecovery], + runCommandInBackground(() => promptsPagination.onLoadMore(), "ambient"), + [promptsPagination, runCommandInBackground], ); const onLoadMoreResources = useCallback( () => - void runWithCommandAuthRecovery( - () => resourcesPagination.onLoadMore(), - "ambient", - ), - [resourcesPagination, runWithCommandAuthRecovery], + runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"), + [resourcesPagination, runCommandInBackground], ); const toolsPaginationControls: ListPaginationControlsProps = { paginated: toolsPagination.paginated, @@ -3676,16 +3689,12 @@ function App() { onLoadMore: onLoadMoreResources, }; const onRefreshTasks = useCallback(() => { - void runWithCommandAuthRecovery(() => refreshTasks(), "ambient")?.catch( - (err: unknown) => { - notifications.show({ - title: "Failed to refresh tasks", - message: err instanceof Error ? err.message : String(err), - color: "red", - }); - }, + runCommandInBackground( + () => refreshTasks(), + "ambient", + "Failed to refresh tasks", ); - }, [refreshTasks, runWithCommandAuthRecovery]); + }, [refreshTasks, runCommandInBackground]); const onClearLogs = useCallback(() => { if (!messageLogState) return; From 9f42a5ef10e232a53540c5bbf68e9d39e95d3cd7 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 18 Aug 2026 10:52:24 -0400 Subject: [PATCH 225/941] test: narrow the fake-client cast to a single assertion Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/src/App.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 09d1e67e..fdf01413 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -2552,7 +2552,9 @@ describe("App background command rejections (#2049)", () => { const rejections = captureUnhandledRejections(); try { const user = await connect(); - const client = clientInstances[0] as unknown as { + // Single assertion: the fake client IS an EventTarget, so narrowing it + // to that intersection needs no `as unknown as` detour. + const client = clientInstances[0] as EventTarget & { setLoggingLevel: ReturnType<typeof vi.fn>; }; client.setLoggingLevel.mockRejectedValueOnce(new Error("no logging")); From 7ea75e94f145440b0e7d106bdf8c0b735e5c91d4 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 18 Aug 2026 19:37:06 -0400 Subject: [PATCH 226/941] chore: bump version to 2.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the v2.3.0 release, following the procedure established in #2010 / #2011: the bump lands on v2/main before the milestone merge, so it flows into main with the rest of the milestone's work rather than being stranded downstream. Applied with `npm version minor --no-git-tag-version` — no tag is created here, since the release tag belongs on the main merge commit (step 3). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com> --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb4866ac..03b146dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/inspector", - "version": "2.2.0", + "version": "2.3.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4e0295ac..11ddd478 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/inspector", - "version": "2.2.0", + "version": "2.3.0", "description": "The Model Context Protocol Inspector", "keywords": [ "MCP", From 1edb4f78c7707525623daebd967a9cd485588bb8 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 18 Aug 2026 22:20:10 -0400 Subject: [PATCH 227/941] chore(deps): sync the security fixes that landed on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependabot security updates always target the default branch, so #1833, #1840, #1913, #1925 and #2004 all landed on `main`. `main` is never merged back into `v2/main`, so those fixes had no path to the develop branch and every install off `v2/main` still resolved the vulnerable versions — Dependabot will not re-raise them, since it considers them fixed on the branch it watches. Brings v2/main to parity: hono 4.12.25 -> 4.13.1, undici 8.5.0 -> 8.9.0, body-parser 2.2.2 -> 2.3.0, ip-address 10.1.0 -> 10.5.0, fast-uri 3.1.0 -> 3.1.5, plus qs, type-is, express-rate-limit, hasown, side-channel and es-object-atoms, across the root, web, cli and tui installs. Two direct declarations move with them: cli undici ^8.9.0 and web hono ^4.13.1. The files are taken from the reconciled v2.3.0 milestone-merge tree, so the root version stays 2.3.0 rather than regressing to main's 2.2.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/cli/package-lock.json | 14 ++-- clients/cli/package.json | 2 +- clients/tui/package-lock.json | 6 +- clients/web/package-lock.json | 8 +-- clients/web/package.json | 2 +- package-lock.json | 122 +++++++++++++++++++++------------- 6 files changed, 93 insertions(+), 61 deletions(-) diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 0e30913b..f81d37e6 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -14,7 +14,7 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", + "undici": "^8.9.0", "zod": "^4.4.3" }, "bin": { @@ -2800,9 +2800,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -4441,9 +4441,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" diff --git a/clients/cli/package.json b/clients/cli/package.json index 54f2dc5c..08fa3eb2 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -38,7 +38,7 @@ "commander": "^13.1.0", "open": "^10.2.0", "pino": "^9.14.0", - "undici": "^8.5.0", + "undici": "^8.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 42812dbb..062b7e2b 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -3247,9 +3247,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 6bbfc0e0..26f91ccf 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -20,7 +20,7 @@ "atomically": "^2.1.1", "chokidar": "^4.0.3", "commander": "^13.1.0", - "hono": "^4.12.18", + "hono": "^4.13.1", "open": "^10.2.0", "papaparse": "^5.5.4", "pino": "^9.14.0", @@ -5873,9 +5873,9 @@ } }, "node_modules/hono": { - "version": "4.12.33", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", - "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/clients/web/package.json b/clients/web/package.json index cc0de377..00de16b1 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -46,7 +46,7 @@ "atomically": "^2.1.1", "chokidar": "^4.0.3", "commander": "^13.1.0", - "hono": "^4.12.18", + "hono": "^4.13.1", "open": "^10.2.0", "papaparse": "^5.5.4", "pino": "^9.14.0", diff --git a/package-lock.json b/package-lock.json index 03b146dd..b8e83a3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1399,20 +1399,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -1422,6 +1422,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", @@ -1807,9 +1820,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2110,12 +2123,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -2148,9 +2162,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -2393,9 +2407,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2405,9 +2419,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2537,9 +2551,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "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" @@ -3463,12 +3477,13 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -3746,14 +3761,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4040,17 +4055,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -4092,9 +4124,9 @@ } }, "node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "license": "MIT", "engines": { "node": ">=22.19.0" From 180b397aec0c5454b6dea17c2d5a25be123313f6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Tue, 18 Aug 2026 23:12:10 -0400 Subject: [PATCH 228/941] chore(deps): bump nanoid to 3.3.18 to clear the high-severity advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `npm audit --omit=dev` reported one high-severity finding in the production tree, on both `main` and `v2/main`: nanoid <3.3.18 — GHSA-2v37-7h3g-55p8 custom generators can loop indefinitely when size is zero It arrives transitively, via vite -> postcss -> nanoid. `postcss` depends on `nanoid@^3.3.11`, so the fix is an in-range lockfile bump in all five installs — no package.json changed. `npm audit --omit=dev` is now clean at the root and in each of the four client installs. Applied as a targeted `npm update nanoid` per install rather than `npm audit fix`. The latter reaches the same clean audit but re-resolves the whole tree, and here dragged in an unrelated esbuild downgrade (0.27.7 -> 0.27.2) across web/cli/tui — roughly 700 lines of lockfile noise for a one-package security patch. The issue also asked whether `vite` belongs in root `dependencies`, since that placement is why this advisory appears under `--omit=dev` at all. It does, and deliberately: `start-vite-dev-server.ts` imports `vite` and `@vitejs/plugin-react` at runtime for `--web --dev`, and `tsup.runner.config.ts` lists both as `external`, so a published install resolves them from the root manifest. Moving them to `devDependencies` would break `--web --dev` for consumers while passing every local check. Left in place, with the reasoning recorded in the README's dependency-placement paragraph so it need not be re-derived. Verified: `npm audit --omit=dev` reports 0 vulnerabilities in all five installs; `npm run ci` passes. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 2 +- clients/cli/package-lock.json | 6 +++--- clients/launcher/package-lock.json | 6 +++--- clients/tui/package-lock.json | 6 +++--- clients/web/package-lock.json | 6 +++--- package-lock.json | 6 +++--- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 23142a2f..2c420c00 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ npm install # root install; postinstall cascades into every client The cascade (`scripts/install-clients.mjs`) is dev-only — it exits early when the package is installed as a dependency, and the published tarball ships only each client's `build/`, so end users are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip it. -**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`. +**Where a dependency is declared.** The MCP SDK packages (`@modelcontextprotocol/client`, `core`, `server`, `server-legacy`, `ext-apps`) live in the **root** `package.json` only — never in a client's. Node resolution walks up, so the root install is on every client's chain, and the root manifest is already what the published tarball resolves against. Declaring them per client installs a second copy that can drift from the root's, which is how two versions of `ext-apps` (and of the transitive v1 `@modelcontextprotocol/sdk`) ended up in the tree before [#1970](https://github.com/modelcontextprotocol/inspector/issues/1970) — and a second copy of `client`/`core` is the failure `vitest.shared.mts` carries a `dedupe` workaround for. The same root-only placement holds for anything reached solely through root-owned code with no manifest of its own (`test-servers/src`, `core/`), and `vitest.shared.mts` aliases those to the repo root — `express` and `yaml`, both reached through `test-servers/src`, are the two today. **Whether such a package is a `dependency` or a `devDependency` follows from who consumes it at runtime, not from where it is declared:** anything `core/` imports at runtime must be a root **`dependency`**, because the client builds externalize npm packages and a published install resolves them from the root manifest, where devDependencies are absent. `express` is test-only and is a devDependency; `yaml` currently sits in `dependencies`. **`vite` and `@vitejs/plugin-react` are root `dependencies` for the same reason, not by mistake** — they look like build tooling, but `clients/web/server/start-vite-dev-server.ts` imports them at runtime for `mcp-inspector --web --dev`, and `clients/web/tsup.runner.config.ts` lists both as `external`, so a published install resolves them from the root manifest. Moving them to `devDependencies` would break `--web --dev` for consumers (and the on-demand `vite build` in `ensure-web-build.ts`) while passing every local check. It does mean they show up under `npm audit --omit=dev`, which is a feature: they really are in the production tree. ## Running during development diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index f81d37e6..30a14836 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -3558,9 +3558,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": [ { diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 93c1e23b..e9d690fc 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -1959,9 +1959,9 @@ "license": "MIT" }, "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": [ { diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 062b7e2b..9261589c 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -4295,9 +4295,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": [ { diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 26f91ccf..b92eff57 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -7626,9 +7626,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": [ { diff --git a/package-lock.json b/package-lock.json index b8e83a3e..d934e96e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3105,9 +3105,9 @@ "license": "MIT" }, "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==", "funding": [ { "type": "github", From f8e97c99a199683ac93863902f74950bc5d4f323 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Thu, 20 Aug 2026 19:34:41 -0400 Subject: [PATCH 229/941] ci: drop the redundant PR-head checkout from claude.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code scanning alert 71 (actions/untrusted-checkout/high) has stayed open against main at the `Checkout PR branch` step — the same step #1966 added the `is_fork == 'false'` guard to. The rule is syntactic, so it cannot see that guard, the job-level author_association check, or the repo's `collaborators_only` fork-PR policy; no hardening around the step clears it while the step exists. The step turns out to be redundant anyway. claude-code-action fetches and checks out the PR branch itself, and its own docs name a bare base-ref checkout as the preferred pattern — which is what upstream's own claude.yml does. Our lookup-then-checkout pair is vestigial from the v1 example workflow inherited in #1869. So: delete the PR-head checkout, make the base checkout unconditional, and drop the now-unused `sha` output. The `is_fork` gate on Run Claude Code stays, and is now the only thing keeping fork code out of the workspace — the action fetches a cross-repository head via refs/pull/N/head on its own. Closes #2069 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H Signed-off-by: cliffhall <cliff@futurescale.com> --- .github/workflows/claude.yml | 54 +++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index f68ae527..b35b91c3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -40,6 +40,9 @@ jobs: # force-moved tag would be an unreviewed code change inside a # secret-holding job. The trailing `# vX.Y.Z` comment is the form # Dependabot reads, so pinning costs us no upgrade automation. (#1882) + + # This lookup exists only to answer "is the head in a fork?". The action + # below checks out the PR branch itself, so no `sha` is needed here. - name: Get PR details if: | (github.event_name == 'issue_comment' && github.event.issue.pull_request) || @@ -64,53 +67,52 @@ jobs: // A fork PR's head lives in a different repository — or in none at // all, if the fork was deleted after the PR was opened (`head.repo` - // is then null). Neither is ours to check out, so both count as a + // is then null). Neither is ours to run against, so both count as a // fork here and the steps below decline rather than guess. const headRepo = pr.data.head.repo?.full_name ?? null; const isFork = headRepo !== `${context.repo.owner}/${context.repo.repo}`; - core.setOutput('sha', pr.data.head.sha); core.setOutput('is_fork', String(isFork)); - # A fork PR's head is untrusted code, and checking it out would put it in - # reach of a tool-enabled agent run holding ANTHROPIC_API_KEY. Reviewing - # the base tree instead would only trade that for a confident review of - # the wrong tree, so decline visibly and leave the reason in the run. + # A fork PR's head is untrusted code, and running a tool-enabled agent + # holding ANTHROPIC_API_KEY against it is what this declines. The gate is + # load-bearing even though we no longer check the head out ourselves: + # claude-code-action fetches a cross-repository head via + # `refs/pull/N/head` on its own (see `isCrossRepository` in its + # src/github/operations/branch.ts), so skipping the action is the only + # thing that keeps that code out of the workspace. Reviewing the base tree + # instead would only trade the problem for a confident review of the wrong + # tree, so decline visibly and leave the reason in the run. - name: Decline fork PR if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'true' run: | { echo "### Claude Code declined this pull request" echo - echo "The head branch lives in a fork, so its code is not checked out and Claude is not run." + echo "The head branch lives in a fork, so Claude is not run against it." echo "Push the branch to this repository and re-trigger if a review is needed." } >> "$GITHUB_STEP_SUMMARY" - # No `repository:` — a same-repo head is all that reaches this step, and - # checkout defaults to `github.repository`, which no PR can influence. - - name: Checkout PR branch - if: steps.pr.outcome == 'success' && steps.pr.outputs.is_fork == 'false' - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ steps.pr.outputs.sha }} - fetch-depth: 0 - - # `skipped` means the trigger was an issue or a non-PR comment, so there - # is no head to check out and the base tree is the right one. The lookup - # having *failed* is deliberately not included: this condition carries no - # status-check function, so GitHub applies an implicit `success()` and - # skips the step after a failed prior step anyway. Spelling out `skipped` - # keeps that from having to be re-derived by the next reader. + # Base ref only — no `ref:` input, so nothing a PR controls selects what + # is checked out. On a PR, claude-code-action then fetches and checks out + # the PR branch itself, which is why a second PR-head checkout here would + # be redundant. It was also the one thing keeping CodeQL's + # `actions/untrusted-checkout` alert open: the rule is syntactic and + # cannot see the `is_fork` gate, the job-level `author_association` check, + # or the repo's `collaborators_only` fork-PR policy. (#2069) - name: Checkout repository - if: steps.pr.outcome == 'skipped' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Run Claude Code - # Runs only against a tree this workflow actually checked out: the base - # repo (non-PR trigger) or a same-repo PR head. A fork PR reaches here - # with `is_fork == 'true'` and both disjuncts false, so it is skipped. + # `skipped` means the trigger was an issue or a non-PR comment. A + # same-repo PR gives `is_fork == 'false'`. A fork PR reaches here with + # `is_fork == 'true'` and both disjuncts false, so it is skipped — and + # so the action never fetches its head. A *failed* lookup is excluded + # too: this condition carries no status-check function, so GitHub + # applies an implicit `success()` and skips the step after a failed + # prior step regardless of what the comparisons say. if: steps.pr.outcome == 'skipped' || steps.pr.outputs.is_fork == 'false' id: claude uses: anthropics/claude-code-action@5ef2e550a465a721f4f45e4a7d3c340c873e1dcc # v1.0.190 From bc408f58692af5950aab16dd8e85f6d509c6f80d Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 12:06:31 -0400 Subject: [PATCH 230/941] feat(auth): file-backed and in-memory SecretStore for hosts with no keychain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1950 Before this, a box with no reachable OS keychain — the published container (no D-Bus session, #1848), Android/Termux (no platform binary, #1905), a minimal Linux install without libsecret — could not persist an OAuth client secret or a stdio `env:` value at all. `KeyringSecretStore` degraded into a store that silently holds nothing: `get` returned null, `delete` no-opped, and `set` hard-failed with a 503. That is the correct failure behavior, but it left the documented, supported way to run the Inspector with no secret persistence whatsoever. Adds the two missing implementations and, more importantly, the policy that picks one and the surfaces that say which was picked. - `FileSecretStore` — one `0600` JSON document at `~/.mcp-inspector/secrets.json`, encrypted with AES-256-GCM whenever `MCP_INSPECTOR_SECRET_KEY` is set (scrypt, per-file random salt). The whole map is encrypted as a unit rather than value-by-value so the account names — `${serverId}:${field}` — are hidden too; a per-value scheme would leave a readable index of which servers you hold a client secret for. A file that can no longer be decrypted reads as empty (matching the interface's read tolerance) but *refuses to be written*, because replacing a file of still-valid secrets to satisfy an additive request destroys data. - `secret-store-selection.ts` — the policy. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright; otherwise probe the keychain and, if unreachable, fall back loudly. The fallback is `memory` in a container whose secrets directory is not on a mount, and `file` otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `--rm` will discard. Cached per process so the banner, `/api/config`, and the store doing the writing cannot disagree. - Automatic, but never silent. The downgrade prints at startup, rides `GET /api/config`, and is named in a permanent 32px footer at the bottom of both dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is the thing a user dismisses before doing the work it describes. The plaintext and memory cases render their caveat inline in a warning tone — a warning you must hover to discover is not one. - `SecretStoreUnavailableError` is now the base every store's `set` throws, so the routes translate one condition into a 503 instead of matching `KeychainUnavailableError` by name. Docs: the container section of the root README gains a table of which store you get and how to make secrets survive, plus the `MCP_INSPECTOR_SECRET_KEY` upgrade/mismatch rules. Tests: the existing interface contract block is extracted to `secretStoreContract.ts` and run against all three implementations, so none can drift on the availability contract. `FileSecretStore` is tested against a real temp filesystem — mode, cross-instance round-trip, that the ciphertext does not contain the plaintext, the read-modify-write race, and every refusal path. Full `npm run ci` passes, per-file gate included. Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 24 +- README.md | 27 +- clients/web/server/server.ts | 9 +- clients/web/server/vite-hono-plugin.ts | 7 +- clients/web/server/web-server-config.ts | 22 +- clients/web/src/App.css | 10 + clients/web/src/App.tsx | 3 + .../SecretStorageFooter.stories.tsx | 105 +++++ .../SecretStorageFooter.test.tsx | 84 ++++ .../SecretStorageFooter.tsx | 139 ++++++ .../ClientSettingsModal.test.tsx | 42 ++ .../ClientSettingsModal.tsx | 11 + .../ServerSettingsModal.test.tsx | 40 ++ .../ServerSettingsModal.tsx | 11 + .../core/auth/secret-storage-info.test.ts | 117 +++++ .../test/core/react/useInitialConfig.test.tsx | 51 +++ .../auth/node/file-secret-store.test.ts | 411 ++++++++++++++++++ .../auth/node/secret-store-selection.test.ts | 384 ++++++++++++++++ .../auth/node/secret-store.test.ts | 193 ++++---- .../auth/node/secretStoreContract.ts | 146 +++++++ .../server/web-server-config.test.ts | 54 +++ clients/web/src/theme/Group.ts | 28 ++ core/auth/node/file-secret-store.ts | 400 +++++++++++++++++ core/auth/node/secret-store-selection.ts | 323 ++++++++++++++ core/auth/node/secret-store.ts | 105 ++++- core/auth/secret-storage-info.ts | 124 ++++++ core/client/node-persistence.ts | 4 +- core/client/runner.ts | 13 +- core/mcp/node/servers.ts | 11 +- core/mcp/remote/node/server.ts | 43 +- core/react/useInitialConfig.ts | 32 +- 31 files changed, 2827 insertions(+), 146 deletions(-) create mode 100644 clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx create mode 100644 clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx create mode 100644 clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx create mode 100644 clients/web/src/test/core/auth/secret-storage-info.test.ts create mode 100644 clients/web/src/test/integration/auth/node/file-secret-store.test.ts create mode 100644 clients/web/src/test/integration/auth/node/secret-store-selection.test.ts create mode 100644 clients/web/src/test/integration/auth/node/secretStoreContract.ts create mode 100644 core/auth/node/file-secret-store.ts create mode 100644 core/auth/node/secret-store-selection.ts create mode 100644 core/auth/secret-storage-info.ts diff --git a/AGENTS.md b/AGENTS.md index 0771175e..04f365f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,10 +51,30 @@ v2/main/ │ │ # wrapper that rewrites the discovered AS │ │ # metadata document, the one seam SDK v2 routes │ │ # BOTH endpoints through (neither reaches the -│ │ # OAuthClientProvider) — #1906) +│ │ # OAuthClientProvider) — #1906; +│ │ # secret-storage-info.ts browser-safe +│ │ # descriptor of WHERE a typed secret lands +│ │ # — kind/plaintext/durable plus the label, +│ │ # tone, caveat and summary helpers shared +│ │ # by the startup banner and the web UI, so +│ │ # terminal and browser cannot describe the +│ │ # same store differently — #1950) │ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation) │ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer, -│ │ │ # runner-interactive-oauth loopback callback flow) +│ │ │ # runner-interactive-oauth loopback callback flow); +│ │ │ # plus the SecretStore backends and their selection — +│ │ │ # secret-store.ts (KeyringSecretStore + InMemorySecretStore, +│ │ │ # the SecretStoreUnavailableError base every store's `set` +│ │ │ # throws and the routes turn into a 503, and the keychain +│ │ │ # probe), file-secret-store.ts (0600 JSON, AES-256-GCM when +│ │ │ # MCP_INSPECTOR_SECRET_KEY is set — refuses to overwrite a +│ │ │ # file it cannot decrypt rather than destroying it), and +│ │ │ # secret-store-selection.ts (the POLICY: explicit +│ │ │ # MCP_INSPECTOR_SECRET_STORE wins, else probe the keychain, +│ │ │ # else fall back LOUDLY — to memory in a container with +│ │ │ # nothing mounted, to file otherwise; cached per process so +│ │ │ # the banner, /api/config and the store doing the writing +│ │ │ # cannot disagree — #1950) │ │ └── remote/ # Remote OAuth storage (delegates to the remote server) │ ├── client/ # Install-level client config (`client.json`): browser-safe │ │ # parse/validate (config-parse.ts) + Node load/save diff --git a/README.md b/README.md index 2c420c00..97e75e05 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ └── launcher/ # Shared launcher — provides the `mcp-inspector` bin, dispatches to web/cli/tui ├── core/ # Shared code consumed via the `@inspector/core` alias (no package.json) -│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends) +│ ├── auth/ # OAuth: providers, discovery, storage, endpoint overrides, mid-session recovery (browser/node/remote backends); +│ │ # plus per-server secret storage — the keychain/file/memory SecretStore +│ │ # implementations, the selection policy, and the descriptor the banner and UI report │ ├── client/ # Install-level client config (`client.json`): browser-safe parse/validate + Node load/save, remote backend, secrets │ ├── json/ # JSON + parameter/argument conversion utilities, and the nullable-union │ │ # schema collapse shared by the web and TUI form builders @@ -455,6 +457,29 @@ docker run --rm -p 127.0.0.1:6274:6274 \ The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. +**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does *not* write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive: + +| Situation | Store | Secrets survive a restart? | +| --- | --- | --- | +| Keychain reachable (a normal desktop install) | OS keychain | Yes | +| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only | +| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | + +So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. + +**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` to any passphrase and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone: + +```bash +docker run --rm -p 127.0.0.1:6274:6274 \ + -v mcp-inspector-data:/home/node/.mcp-inspector \ + -e MCP_INSPECTOR_SECRET_KEY="$MY_PASSPHRASE" \ + ghcr.io/modelcontextprotocol/inspector +``` + +Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. **Changing or losing it is not**: a file that can no longer be decrypted is read as empty and *refuses to be written*, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. + +Two env vars override the automatic choice: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, and `MCP_INSPECTOR_SECRET_FILE` moves the file. + **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: ```bash diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index fe465530..39476682 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -18,6 +18,7 @@ import { formatHostForUrl } from "../../../core/node/hostUrl.ts"; import { createSandboxController } from "./sandbox-controller.js"; import { injectAuthToken } from "./inject-auth-token.js"; import type { WebServerConfig } from "./web-server-config.js"; +import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts"; import { webServerConfigToInitialPayload, printServerBanner, @@ -49,6 +50,11 @@ export async function startHonoServer( (config.dangerouslyOmitAuth ? "" : randomBytes(32).toString("hex")); const rootPath = config.staticRoot ?? __dirname; + // Resolve the secret store before the API is built so the descriptor the + // browser reads (`/api/config`) and the one the banner prints come from the + // same resolution — the selection is cached, so this is also the only place + // the keychain probe actually runs. + const secretStorage = await getSecretStorageInfo(); const { app: apiApp, close: closeApi } = createRemoteApp({ authToken: config.dangerouslyOmitAuth ? undefined : resolvedAuthToken, @@ -60,7 +66,7 @@ export async function startHonoServer( allowedOrigins: config.allowedOrigins, sandboxUrl: sandboxController.getUrl() ?? undefined, logger: config.logger, - initialConfig: webServerConfigToInitialPayload(config), + initialConfig: webServerConfigToInitialPayload(config, secretStorage), }); const app = new Hono(); @@ -123,6 +129,7 @@ export async function startHonoServer( info.port, resolvedAuthToken, sandboxUrl ?? undefined, + secretStorage, ); if (config.autoOpen) { // Never a bare `open(url)`: this callback is synchronous, so a diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 880913e6..1be0674b 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -18,6 +18,7 @@ import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts"; import { createSandboxController } from "./sandbox-controller.js"; import { injectAuthToken } from "./inject-auth-token.js"; import type { WebServerConfig } from "./web-server-config.js"; +import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts"; import { webServerConfigToInitialPayload, printServerBanner, @@ -66,6 +67,9 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { allowedOrigins: config.allowedOrigins, }); await sandboxController.start(); + // Resolved before the API is built so `/api/config` and the banner + // report the same store (the selection itself is cached process-wide). + const secretStorage = await getSecretStorageInfo(); const { app: honoApp, @@ -81,7 +85,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { allowedOrigins: config.allowedOrigins, sandboxUrl: sandboxController.getUrl() ?? undefined, logger: config.logger, - initialConfig: webServerConfigToInitialPayload(config), + initialConfig: webServerConfigToInitialPayload(config, secretStorage), }); // Expose the resolved token to `transformIndexHtml`. Left empty when @@ -111,6 +115,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { actualPort, resolvedToken, sandboxUrl ?? undefined, + secretStorage, ); if (config.autoOpen) { diff --git a/clients/web/server/web-server-config.ts b/clients/web/server/web-server-config.ts index 6bb13be2..a4359728 100644 --- a/clients/web/server/web-server-config.ts +++ b/clients/web/server/web-server-config.ts @@ -15,6 +15,8 @@ import { LEGACY_AUTH_TOKEN_ENV, } from "../../../core/mcp/remote/constants.ts"; import type { InitialConfigPayload } from "../../../core/mcp/remote/node/server.ts"; +import type { SecretStorageInfo } from "../../../core/auth/secret-storage-info.ts"; +import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts"; import { readInspectorVersionSafe } from "../../../core/node/version.ts"; import { resolveSandboxPort } from "./sandbox-controller.js"; import { resolveBindHostname } from "./resolve-bind-host.js"; @@ -112,8 +114,17 @@ function defaultEnvironmentFromProcess( */ export function webServerConfigToInitialPayload( config: WebServerConfig, + secretStorage?: SecretStorageInfo, ): InitialConfigPayload { - return { ...transportDefaults(config), version: inspectorVersion }; + return { + ...transportDefaults(config), + version: inspectorVersion, + // Omitted rather than defaulted when the caller didn't resolve a store: + // the UI treats an absent descriptor as "unknown" and shows nothing, + // which is the only honest rendering. A default of "keychain" would put a + // confident wrong answer under the field where a secret is typed. + ...(secretStorage ? { secretStorage } : {}), + }; } /** The transport-specific defaults half of the `/api/config` payload. */ @@ -168,6 +179,7 @@ export function printServerBanner( actualPort: number, resolvedToken: string, sandboxUrl: string | undefined, + secretStorage?: SecretStorageInfo, ): string { // Advertise `localhost` for a wildcard bind (`http://0.0.0.0:PORT` is an // awkward URL to click and points the user at a reachable, allow-listed @@ -197,6 +209,14 @@ export function printServerBanner( console.log(` Auth token: ${resolvedToken}\n`); } + // Where a secret typed into this session lands (#1950). Printed for every + // store, not only the surprising ones: the fallback already warns + // separately at selection time, and a line that appears only when + // something is wrong can't be used to confirm that nothing is. + if (secretStorage) { + console.log(` Secrets: ${secretStorageSummary(secretStorage)}\n`); + } + if (config.autoOpen) { console.log("Opening browser..."); } diff --git a/clients/web/src/App.css b/clients/web/src/App.css index fb5d1aab..b208285c 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -24,6 +24,13 @@ light surfaces; a lighter red on the dark card/header (see dark override) — `red-6` fell just under WCAG AA on both. */ --inspector-danger-text: var(--mantine-color-red-7); + /* Caution text (the secret-storage footer when secrets are unencrypted or + session-only, #1950). Yellow is the right *meaning* — this is "know what + you are doing", not "this failed" — but yellow is also the hardest hue to + make readable: `yellow-6`, the tone the log-level badges use on a filled + chip, is far under AA as body text on a light surface. `yellow-8` clears it + here, and the dark override lifts to `yellow-4` the same way danger does. */ + --inspector-warning-text: var(--mantine-color-yellow-8); /* ── Surfaces ──────────────────────────────────────────── */ /* Light-grey app background so the white cards read as raised. Drives @@ -204,6 +211,9 @@ --mantine-color-dimmed: var(--mantine-color-dark-1) !important; /* Lighter danger red for the dark card/header (deep red would fail AA). */ --inspector-danger-text: var(--mantine-color-red-4); + /* Same inversion for caution text: the deep `yellow-8` is unreadable on a + dark surface. */ + --inspector-warning-text: var(--mantine-color-yellow-4); } /* A11y contrast (light): Mantine sets these per-scheme on a selector more diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index db29e06b..bae88517 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -741,6 +741,7 @@ function App() { sandboxUrl, writable: serverListWritable, version: inspectorVersion, + secretStorage, } = useInitialConfig({ baseUrl: configBaseUrl, authToken: getAuthToken(), @@ -4618,6 +4619,7 @@ function App() { onClearStoredOAuth={ settingsModalIsStdio ? undefined : handleClearStoredOAuthFromSettings } + secretStorage={secretStorage} /> <ClientSettingsModal key={ @@ -4629,6 +4631,7 @@ function App() { onSettingsChange={onClientSettingsChange} emaIdpLoginState={emaIdpLoginState} onEmaIdpLogout={logoutEmaIdp} + secretStorage={secretStorage} /> {initializeResult && activeServer && ( <ConnectionInfoModal diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx new file mode 100644 index 00000000..061b1212 --- /dev/null +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx @@ -0,0 +1,105 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; +import { SecretStorageFooter } from "./SecretStorageFooter"; + +const keyring: SecretStorageInfo = { + kind: "keyring", + reason: "default", + durable: true, +}; + +const meta: Meta<typeof SecretStorageFooter> = { + title: "Elements/SecretStorageFooter", + component: SecretStorageFooter, + args: { info: keyring }, +}; + +export default meta; +type Story = StoryObj<typeof SecretStorageFooter>; + +// The ordinary case on a desktop install: the OS keychain, stated quietly. +export const Keychain: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText("Secrets: OS keychain"), + ).toBeInTheDocument(); + await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "neutral", + ); + }, +}; + +// A container with a mounted volume and `MCP_INSPECTOR_SECRET_KEY` set: durable, +// encrypted, and therefore still quiet — the path is the useful part. +export const EncryptedFile: Story = { + args: { + info: { + kind: "file", + reason: "fallback", + durable: true, + plaintext: false, + path: "/home/node/.mcp-inspector/secrets.json", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText("Secrets: File (encrypted)"), + ).toBeInTheDocument(); + await expect( + canvas.getByText("/home/node/.mcp-inspector/secrets.json"), + ).toBeInTheDocument(); + }, +}; + +// The same container without a passphrase. The caveat is rendered inline and in +// the warning tone — this is what "plaintext gets a loud banner" looks like at +// the point where the secret is actually typed. +export const UnencryptedFile: Story = { + args: { + info: { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText("Secrets: File (unencrypted)"), + ).toBeInTheDocument(); + await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "warn", + ); + }, +}; + +// The published container with nothing mounted: session-scoped, and it says so +// rather than promising a durability the writable layer can't keep. +export const InMemory: Story = { + args: { info: { kind: "memory", reason: "fallback", durable: false } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + await canvas.findByText("Secrets: Memory (this session only)"), + ).toBeInTheDocument(); + await expect( + canvas.getByText(/lost when the Inspector exits/), + ).toBeInTheDocument(); + }, +}; + +// A backend that reports no store (legacy build): the band is absent entirely. +export const Unknown: Story = { + args: { info: undefined }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.queryByTestId("secret-storage-footer")).toBeNull(); + }, +}; diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx new file mode 100644 index 00000000..90dca4a2 --- /dev/null +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { SecretStorageFooter } from "./SecretStorageFooter"; + +const keyring: SecretStorageInfo = { + kind: "keyring", + reason: "default", + durable: true, +}; +const encryptedFile: SecretStorageInfo = { + kind: "file", + reason: "fallback", + durable: true, + plaintext: false, + path: "/home/node/.mcp-inspector/secrets.json", +}; +const plaintextFile: SecretStorageInfo = { ...encryptedFile, plaintext: true }; +const memory: SecretStorageInfo = { + kind: "memory", + reason: "fallback", + durable: false, +}; + +describe("SecretStorageFooter", () => { + it("names the keychain with no caveat and no warning tone", () => { + renderWithMantine(<SecretStorageFooter info={keyring} />); + expect(screen.getByText("Secrets: OS keychain")).toBeInTheDocument(); + expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "neutral", + ); + }); + + it("shows the path for an encrypted file, quietly", () => { + // Nothing is wrong here, so the band must not shout — the path is the + // useful part and the tone stays neutral. + renderWithMantine(<SecretStorageFooter info={encryptedFile} />); + expect(screen.getByText("Secrets: File (encrypted)")).toBeInTheDocument(); + expect( + screen.getByText("/home/node/.mcp-inspector/secrets.json"), + ).toBeInTheDocument(); + expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "neutral", + ); + }); + + it("renders the plaintext caveat inline, not only on hover", () => { + // This is the "loud banner" decision made concrete: a warning you have + // to hover to discover is not a warning. The caveat takes the visible + // slot, displacing the path (which stays in the tooltip). + renderWithMantine(<SecretStorageFooter info={plaintextFile} />); + expect(screen.getByText("Secrets: File (unencrypted)")).toBeInTheDocument(); + expect( + screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/), + ).toBeInTheDocument(); + expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "warn", + ); + }); + + it("warns that an in-memory store loses secrets on exit", () => { + renderWithMantine(<SecretStorageFooter info={memory} />); + expect( + screen.getByText("Secrets: Memory (this session only)"), + ).toBeInTheDocument(); + expect( + screen.getByText(/lost when the Inspector exits/), + ).toBeInTheDocument(); + expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "warn", + ); + }); + + it("renders nothing when the backend didn't report a store", () => { + // A guessed answer under a secret field is worse than no answer, so the + // unknown case is silence rather than a default. + renderWithMantine(<SecretStorageFooter info={undefined} />); + expect(screen.queryByTestId("secret-storage-footer")).toBeNull(); + }); +}); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx new file mode 100644 index 00000000..252e6a66 --- /dev/null +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -0,0 +1,139 @@ +import { Group, Text, Tooltip } from "@mantine/core"; +import { TbAlertTriangle, TbDeviceDesktop, TbLock } from "react-icons/tb"; +import { + secretStorageCaveat, + secretStorageLabel, + secretStorageTone, + type SecretStorageInfo, +} from "@inspector/core/auth/secret-storage-info.js"; + +/** + * Height of the footer band. Matches `InspectorView`'s `FOOTER_HEIGHT` + * deliberately: these are the app's only permanent status strips, and a + * modal footer standing a few pixels taller than the one behind it would + * read as a different kind of thing. + */ +export const SECRET_STORAGE_FOOTER_HEIGHT = 32; + +export interface SecretStorageFooterProps { + /** + * The active store, from `GET /api/config`. Renders nothing when + * undefined — a backend that didn't report one leaves the question + * genuinely open, and an invented answer directly beneath a secret field + * is worse than silence. + */ + info?: SecretStorageInfo; +} + +// A thin band, same height as the app footer. Mantine's compound Modal has no +// `Modal.Footer`, so it renders as the last child of `Modal.Content`, after the +// `Modal.Body`, and its theme variant pins it with `position: sticky` — the +// mirror of Mantine's own sticky header, because `Modal.Content` scrolls +// header, body and footer as one. `nowrap` keeps it to one line at any modal +// width; the truncating detail text on the right absorbs a narrow modal. +const FooterBand = Group.withProps({ + variant: "secretStorageFooter", + h: SECRET_STORAGE_FOOTER_HEIGHT, + px: "md", + gap: 6, + align: "center", + wrap: "nowrap", + w: "100%", +}); + +const FooterLabel = Text.withProps({ + size: "xs", + fw: 500, + variant: "nowrap", +}); + +// The detail — a path, or the caveat sentence — is the part allowed to lose +// characters, since the label before it carries the answer. +// +// `lineClamp` rather than `truncate`, and the difference is load-bearing. +// `Modal.Content` sizes its scroll container to its children's **max-content** +// width, and `truncate` sets `white-space: nowrap`, whose max-content is the +// entire sentence — so the band grew the container to 770px inside a 620px +// modal and the *form above it* got clipped at the right edge (measured). +// Clamped text still wraps internally, so its max-content is merely its +// longest word, and the container stays the modal's width; the clamp then +// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1` +// let it shrink to the leftover space rather than to its content. +const FooterDetail = Text.withProps({ + size: "xs", + lineClamp: 1, + miw: 0, + flex: 1, +}); + +const ICON_SIZE = 14; + +/** + * The permanent "here is where your secrets go" strip at the bottom of the + * Client Settings and Server Settings modals (#1950). + * + * Those two dialogs are the only places the Inspector accepts a secret — + * an OAuth client secret, an enterprise IdP client secret, a stdio `env:` + * value — so they are where the answer belongs. The alternatives were + * considered and rejected: a startup banner is seen once, by whoever + * started the process, possibly hours before anyone types anything; a + * toast is seen once and then gone; a dismissible banner is, by design, + * the thing a user makes disappear before doing the work it describes. + * This is always present, is not dismissible, and has no empty state + * beyond "the backend didn't say". + * + * When the store is lossy or unencrypted the caveat is rendered *inline* + * rather than left to the tooltip. That is the whole point of the + * "plaintext gets a loud warning" decision: a warning you have to hover to + * discover is not one. The tooltip carries the full sentence for the case + * where a narrow modal truncates it, and — for the quiet stores — the file + * path. + * + * Deliberately read-only for now. Choosing the store from here is the + * obvious follow-up, but the gap this closes is that users could not find + * out *at all*, and closing that does not require a control. + */ +export function SecretStorageFooter({ info }: SecretStorageFooterProps) { + if (!info) return null; + + const caveat = secretStorageCaveat(info); + const warn = secretStorageTone(info) === "warn"; + const color = warn + ? "var(--inspector-warning-text)" + : "var(--inspector-text-secondary)"; + // The caveat wins the visible slot when there is one; otherwise the path, + // which only the (quiet) encrypted-file case has. + const detail = caveat ?? info.path; + const Icon = warn + ? TbAlertTriangle + : info.kind === "keyring" + ? TbDeviceDesktop + : TbLock; + + const band = ( + <FooterBand + data-testid="secret-storage-footer" + data-tone={warn ? "warn" : "neutral"} + > + {/* A react-icons glyph, not a Mantine factory component — outside the + `.withProps()` rule, and it takes `color` as a prop rather than a + style. */} + <Icon size={ICON_SIZE} color={color} aria-hidden /> + <FooterLabel c={color}>Secrets: {secretStorageLabel(info)}</FooterLabel> + {detail && <FooterDetail c={color}>{detail}</FooterDetail>} + </FooterBand> + ); + + // Tooltip content: the caveat plus the path when both exist (the unencrypted + // file case), since the visible slot can only show one of them. + const tip = [caveat, info.path].filter(Boolean).join(" "); + if (!tip) return band; + + return ( + // `multiline` + a width so the sentence wraps instead of forming a tooltip + // wider than the modal it explains. + <Tooltip label={tip} multiline w={320} position="top" withArrow> + {band} + </Tooltip> + ); +} diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx index 19cc880b..2e02df15 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -471,4 +471,46 @@ describe("ClientSettingsModal", () => { ); expect(screen.queryByText("Client Settings")).not.toBeInTheDocument(); }); + + describe("secret-storage footer (#1950)", () => { + // This dialog holds the enterprise IdP client secret, so it states where + // that secret is about to land — permanently, and beside the field rather + // than in a startup banner nobody watching this screen ever saw. + it("names the active store and warns when the file is unencrypted", () => { + renderWithMantine( + <ClientSettingsModal + opened + settings={EMPTY_CLIENT_SETTINGS} + onClose={vi.fn()} + onSettingsChange={vi.fn()} + secretStorage={{ + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + }} + />, + ); + expect( + screen.getByText("Secrets: File (unencrypted)"), + ).toBeInTheDocument(); + expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( + "data-tone", + "warn", + ); + }); + + it("renders no footer when the backend didn't report a store", () => { + renderWithMantine( + <ClientSettingsModal + opened + settings={EMPTY_CLIENT_SETTINGS} + onClose={vi.fn()} + onSettingsChange={vi.fn()} + />, + ); + expect(screen.queryByTestId("secret-storage-footer")).toBeNull(); + }); + }); }); diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx index 17320037..7e4d1e12 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { CloseButton, Group, Modal, ScrollArea } from "@mantine/core"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; +import { SecretStorageFooter } from "../../elements/SecretStorageFooter/SecretStorageFooter"; +import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; import { ClientSettingsForm, type ClientSettingsSection, @@ -41,6 +43,13 @@ export interface ClientSettingsModalProps { ) => void; emaIdpLoginState?: EmaIdpLoginState; onEmaIdpLogout?: () => void; + /** + * Where secrets typed here end up (#1950). This dialog holds the + * enterprise IdP client secret, so the footer is not decoration — it is + * the answer to the question the field raises. Absent on a backend that + * doesn't report a store; the footer then renders nothing. + */ + secretStorage?: SecretStorageInfo; } export function ClientSettingsModal({ @@ -50,6 +59,7 @@ export function ClientSettingsModal({ onSettingsChange, emaIdpLoginState, onEmaIdpLogout, + secretStorage, }: ClientSettingsModalProps) { const [expandedSections, setExpandedSections] = useState< ClientSettingsSection[] @@ -109,6 +119,7 @@ export function ClientSettingsModal({ revealErrors={revealErrors} /> </Modal.Body> + <SecretStorageFooter info={secretStorage} /> </Modal.Content> </AppModalLg> ); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index e627beb7..5479f22b 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -899,4 +899,44 @@ describe("ServerSettingsModal", () => { }); }); }); + + describe("secret-storage footer (#1950)", () => { + // The dialog holds the OAuth client secret and every stdio `env:` value, so + // the destination of what is typed here is stated permanently rather than + // in a toast that has long since gone. + it("names the active store", () => { + renderWithMantine( + <ServerSettingsModal + opened + settings={emptySettings} + serverType="streamable-http" + isStdio={false} + onClose={vi.fn()} + onSettingsChange={vi.fn()} + secretStorage={{ + kind: "memory", + reason: "fallback", + durable: false, + }} + />, + ); + expect( + screen.getByText("Secrets: Memory (this session only)"), + ).toBeInTheDocument(); + }); + + it("renders no footer when the backend didn't report a store", () => { + renderWithMantine( + <ServerSettingsModal + opened + settings={emptySettings} + serverType="streamable-http" + isStdio={false} + onClose={vi.fn()} + onSettingsChange={vi.fn()} + />, + ); + expect(screen.queryByTestId("secret-storage-footer")).toBeNull(); + }); + }); }); diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx index 35c1fb12..e2af62f5 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx @@ -11,6 +11,8 @@ import type { import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js"; import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js"; import { ListToggle } from "../../elements/ListToggle/ListToggle"; +import { SecretStorageFooter } from "../../elements/SecretStorageFooter/SecretStorageFooter"; +import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; import { ServerSettingsForm, type ServerSettingsSection, @@ -73,6 +75,13 @@ export interface ServerSettingsModalProps { onClose: () => void; onSettingsChange: (settings: InspectorServerSettings) => void; onClearStoredOAuth?: () => void; + /** + * Where secrets typed here end up (#1950). This dialog holds the OAuth + * client secret and every stdio `env:` value, so the footer states the + * destination of what the user is about to type. Absent on a backend + * that doesn't report a store; the footer then renders nothing. + */ + secretStorage?: SecretStorageInfo; } export function ServerSettingsModal({ @@ -84,6 +93,7 @@ export function ServerSettingsModal({ onClose, onSettingsChange, onClearStoredOAuth, + secretStorage, }: ServerSettingsModalProps) { const sections = allSectionsFor(serverType, isStdio); // Initial expansion is the first ("options") section — where Network Log @@ -311,6 +321,7 @@ export function ServerSettingsModal({ onRootChange={handleRootChange} /> </Modal.Body> + <SecretStorageFooter info={secretStorage} /> </Modal.Content> </AppModalLg> ); diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts new file mode 100644 index 00000000..8c55c157 --- /dev/null +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -0,0 +1,117 @@ +/** + * The secret-storage descriptor's presentation helpers (#1950). + * + * These are shared by the terminal banner and the browser footer, which is + * the whole reason they live in `core/auth/` rather than beside either + * consumer — so the cases below are about the *statements* they make, + * particularly which states are loud and which are quiet. A helper that + * quietly stopped warning about a plaintext file would leave both surfaces + * silently wrong at once. + */ +import { describe, it, expect } from "vitest"; +import { + secretStorageCaveat, + secretStorageLabel, + secretStorageSummary, + secretStorageTone, + type SecretStorageInfo, +} from "@inspector/core/auth/secret-storage-info.js"; + +const keyring: SecretStorageInfo = { + kind: "keyring", + reason: "default", + durable: true, +}; +const encryptedFile: SecretStorageInfo = { + kind: "file", + reason: "fallback", + durable: true, + plaintext: false, + path: "/home/node/.mcp-inspector/secrets.json", +}; +const plaintextFile: SecretStorageInfo = { + ...encryptedFile, + plaintext: true, +}; +const memory: SecretStorageInfo = { + kind: "memory", + reason: "fallback", + durable: false, +}; + +describe("secretStorageLabel", () => { + it("names the keychain", () => { + expect(secretStorageLabel(keyring)).toBe("OS keychain"); + }); + + it("distinguishes an encrypted file from an unencrypted one", () => { + // The distinction is the point — "File" alone would answer "where" + // while dropping the half a user needs to judge the risk. + expect(secretStorageLabel(encryptedFile)).toBe("File (encrypted)"); + expect(secretStorageLabel(plaintextFile)).toBe("File (unencrypted)"); + }); + + it("says memory is session-scoped in the label itself", () => { + expect(secretStorageLabel(memory)).toBe("Memory (this session only)"); + }); +}); + +describe("secretStorageTone", () => { + it("stays neutral for the keychain and an encrypted file", () => { + // A tone that fired for every store would carry no information. + expect(secretStorageTone(keyring)).toBe("neutral"); + expect(secretStorageTone(encryptedFile)).toBe("neutral"); + }); + + it("warns for an unencrypted file and for memory", () => { + expect(secretStorageTone(plaintextFile)).toBe("warn"); + expect(secretStorageTone(memory)).toBe("warn"); + }); +}); + +describe("secretStorageCaveat", () => { + it("has none for the quiet stores", () => { + expect(secretStorageCaveat(keyring)).toBeUndefined(); + expect(secretStorageCaveat(encryptedFile)).toBeUndefined(); + }); + + it("names the loss for memory", () => { + expect(secretStorageCaveat(memory)).toContain( + "lost when the Inspector exits", + ); + }); + + it("names the fix for an unencrypted file, not just the problem", () => { + const caveat = secretStorageCaveat(plaintextFile); + expect(caveat).toContain("unencrypted"); + expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY"); + }); + + it("prefers the memory caveat when a memory store somehow carries the flag", () => { + // Defensive ordering: `plaintext` is meaningless for memory, and the + // durability loss is the more consequential of the two statements. + expect(secretStorageCaveat({ ...memory, plaintext: true })).toContain( + "lost when the Inspector exits", + ); + }); +}); + +describe("secretStorageSummary", () => { + it("is just the label when there is nothing else to say", () => { + expect(secretStorageSummary(keyring)).toBe("OS keychain"); + }); + + it("includes the path so a file-backed store is findable", () => { + expect(secretStorageSummary(encryptedFile)).toContain( + "/home/node/.mcp-inspector/secrets.json", + ); + }); + + it("marks a fallback as a fallback", () => { + // The banner has to distinguish "you configured this" from "we had to". + expect(secretStorageSummary(memory)).toContain("fell back"); + expect( + secretStorageSummary({ ...memory, reason: "configured" }), + ).not.toContain("fell back"); + }); +}); diff --git a/clients/web/src/test/core/react/useInitialConfig.test.tsx b/clients/web/src/test/core/react/useInitialConfig.test.tsx index 3d246e88..ecf3aad4 100644 --- a/clients/web/src/test/core/react/useInitialConfig.test.tsx +++ b/clients/web/src/test/core/react/useInitialConfig.test.tsx @@ -249,4 +249,55 @@ describe("useInitialConfig", () => { expect(result.current.version).toBeUndefined(); expect(result.current.writable).toBe(true); }); + + describe("secretStorage (#1950)", () => { + // The footer under a secret field reads this, so the hook's job is to hand + // back only a descriptor it can vouch for. Every rejected shape must land + // on `undefined` — which the footer renders as nothing — rather than on a + // half-populated object that would render a confident wrong answer. + it("passes through a well-formed descriptor", async () => { + const info = { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + }; + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ secretStorage: info })); + + const { result } = renderHook(() => + useInitialConfig({ baseUrl: "http://test.local", fetchFn }), + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.secretStorage).toEqual(info); + }); + + it("is undefined on a backend that omits the field", async () => { + const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); + const { result } = renderHook(() => + useInitialConfig({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.secretStorage).toBeUndefined(); + }); + + it.each([ + ["a non-object", "keyring"], + ["null", null], + ["an unknown kind", { kind: "vault", durable: true }], + ["a missing kind", { durable: true, path: "/x" }], + ])("rejects %s", async (_label, value) => { + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ secretStorage: value })); + const { result } = renderHook(() => + useInitialConfig({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.secretStorage).toBeUndefined(); + }); + }); }); diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts new file mode 100644 index 00000000..96152517 --- /dev/null +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -0,0 +1,411 @@ +/** + * `FileSecretStore` against a real filesystem (#1950). + * + * A real temp directory rather than a mocked `fs`: the properties worth + * asserting here are properties *of the file* — its mode, that a second + * store reading it back sees the same secrets, that the ciphertext does + * not contain the plaintext — and a mocked filesystem can be made to + * agree with any of those without them being true. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + FileSecretStore, + SecretFileKeyMismatchError, + tightenSecretFilePermissions, +} from "@inspector/core/auth/node/file-secret-store.js"; +import { SecretStoreUnavailableError } from "@inspector/core/auth/node/secret-store.js"; +import { SECRET_FIELD_OAUTH_CLIENT_SECRET } from "@inspector/core/auth/secret-fields.js"; +import { describeSecretStoreContract } from "./secretStoreContract.js"; + +let tmpDir: string; +const filePath = (): string => path.join(tmpDir, "secrets.json"); + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-secrets-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +// Both modes must satisfy the same contract — encryption is a property of +// the bytes on disk, not of the interface, and a caller must not be able to +// tell which one it got. +describeSecretStoreContract( + "FileSecretStore (plaintext)", + () => new FileSecretStore({ filePath: filePath() }), +); +describeSecretStoreContract( + "FileSecretStore (encrypted)", + () => new FileSecretStore({ filePath: filePath(), passphrase: "hunter2" }), +); + +describe("FileSecretStore persistence", () => { + it("a second store instance reads what the first wrote", async () => { + const a = new FileSecretStore({ filePath: filePath() }); + await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + const b = new FileSecretStore({ filePath: filePath() }); + expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh"); + }); + + it("round-trips through a real encrypt/decrypt cycle across instances", async () => { + const a = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + await a.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + const b = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(await b.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe("shh"); + }); + + it("writes the file 0600", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + const stat = await fs.stat(filePath()); + // Windows does not model POSIX modes; asserting there would fail for a + // reason that has nothing to do with this code. + if (process.platform !== "win32") { + expect(stat.mode & 0o777).toBe(0o600); + } + }); + + it("concurrent sets do not lose each other (the read-modify-write race)", async () => { + // The route handlers write a server's fields with `Promise.all`, so this + // is the ordinary path. Without the internal queue both calls read the + // same empty map and the second write drops the first entry. + const store = new FileSecretStore({ filePath: filePath() }); + await Promise.all([ + store.set("alpha", "env:A", "1"), + store.set("alpha", "env:B", "2"), + store.set("alpha", "env:C", "3"), + ]); + expect(await store.get("alpha", "env:A")).toBe("1"); + expect(await store.get("alpha", "env:B")).toBe("2"); + expect(await store.get("alpha", "env:C")).toBe("3"); + }); + + it("keeps the file after the last entry is deleted", async () => { + // The file's presence records which encryption mode this install settled + // on; unlinking it would let a later write silently switch modes. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + await store.deleteAllForServer("alpha"); + await expect(fs.stat(filePath())).resolves.toBeDefined(); + }); +}); + +describe("FileSecretStore at rest", () => { + it("plaintext mode stores the value readably (the state the warning is about)", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + expect(store.encrypted).toBe(false); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret"); + const raw = await fs.readFile(filePath(), "utf-8"); + expect(raw).toContain("super-secret"); + expect(JSON.parse(raw).encryption).toBe("none"); + }); + + it("encrypted mode leaves neither the value nor the account name on disk", async () => { + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(store.encrypted).toBe(true); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "super-secret"); + const raw = await fs.readFile(filePath(), "utf-8"); + expect(raw).not.toContain("super-secret"); + // The account names are encrypted too — that's why the map is encrypted + // as a unit rather than value-by-value, since the key list would + // otherwise index which servers hold which kind of secret. + expect(raw).not.toContain("alpha"); + expect(raw).not.toContain(SECRET_FIELD_OAUTH_CLIENT_SECRET); + const parsed = JSON.parse(raw); + expect(parsed.encryption).toBe("aes-256-gcm"); + expect(parsed.kdf.algorithm).toBe("scrypt"); + }); + + it("treats a blank passphrase as no passphrase", async () => { + // `MCP_INSPECTOR_SECRET_KEY=` in a compose file means "off", not a + // zero-length key — and silently encrypting with "" would be worse than + // either reading. + expect( + new FileSecretStore({ filePath: filePath(), passphrase: " " }) + .encrypted, + ).toBe(false); + }); + + it("upgrades an existing plaintext file to encrypted on the next write", async () => { + const plain = new FileSecretStore({ filePath: filePath() }); + await plain.set("alpha", "env:A", "1"); + const encrypted = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + // Reads the plaintext file it inherited... + expect(await encrypted.get("alpha", "env:A")).toBe("1"); + // ...and the next write carries the old entry into the encrypted form, + // so setting the env var does not strand the secrets already stored. + await encrypted.set("alpha", "env:B", "2"); + const raw = await fs.readFile(filePath(), "utf-8"); + expect(JSON.parse(raw).encryption).toBe("aes-256-gcm"); + expect(await encrypted.get("alpha", "env:A")).toBe("1"); + }); +}); + +describe("FileSecretStore failure handling", () => { + const writeEncryptedFixture = async (): Promise<void> => { + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", + }); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + }; + + it("get returns null when the passphrase is wrong (read tolerance)", async () => { + await writeEncryptedFixture(); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "wrong-key", + }); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + }); + + it("set refuses rather than overwriting a file it cannot decrypt", async () => { + await writeEncryptedFixture(); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "wrong-key", + }); + await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf( + SecretFileKeyMismatchError, + ); + // The original secrets survive: the refusal is the whole point. + const original = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", + }); + expect(await original.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "shh", + ); + }); + + it("set names the missing passphrase when the key was removed entirely", async () => { + await writeEncryptedFixture(); + const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( + /MCP_INSPECTOR_SECRET_KEY is not set/, + ); + }); + + it("reads a plaintext file that carries no secrets key as empty", async () => { + // A hand-edited (or hand-created) file is the realistic source of this + // shape, and it must read as "no secrets yet" rather than throwing: the + // document is well-formed and declares its encryption mode, so the only + // thing missing is entries. Flattening it to `{}` is also what lets the + // next `set` add one instead of refusing. + await fs.writeFile( + filePath(), + JSON.stringify({ version: 1, encryption: "none" }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "shh", + ); + }); + + it("delete stays silent on a file it cannot decrypt", async () => { + await writeEncryptedFixture(); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "wrong-key", + }); + await expect( + store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).resolves.toBeUndefined(); + await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); + }); + + it("set reports a corrupt file rather than silently replacing it", async () => { + await fs.writeFile(filePath(), "{ not json", "utf-8"); + const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf( + SecretStoreUnavailableError, + ); + expect(await store.get("alpha", "env:A")).toBe(null); + }); + + it("set reports a file written in a format this build doesn't know", async () => { + // The forward-compat case: a newer Inspector's file, opened by an older + // one. Refusing beats truncating it to the entries we happen to parse. + await fs.writeFile( + filePath(), + JSON.stringify({ version: 99, encryption: "chacha20-poly1305" }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( + /unsupported format/, + ); + }); + + it("set reports a truncated ciphertext payload", async () => { + await writeEncryptedFixture(); + const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8")); + parsed.data = "only-one-part"; + await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8"); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", + }); + await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf( + SecretFileKeyMismatchError, + ); + }); + + it("reports a file whose KDF parameters are unusable", async () => { + // scrypt rejects on out-of-grammar cost parameters (`N` must be a power of + // two > 1). Those parameters come off disk, so this is a corrupt-file case + // reachable without touching the crypto call site — and it must land as a + // refusal, not as an exception escaping the derivation. + await writeEncryptedFixture(); + const parsed = JSON.parse(await fs.readFile(filePath(), "utf-8")); + parsed.kdf.N = 3; + await fs.writeFile(filePath(), JSON.stringify(parsed), "utf-8"); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", + }); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf( + SecretFileKeyMismatchError, + ); + }); + + it("reports a read failure that isn't a missing file", async () => { + // A directory where the file should be: `readStoreFile` only translates + // ENOENT into "no file yet", so anything else must surface as a refusal + // rather than being read as an empty store and overwritten. + const asDir = path.join(tmpDir, "as-dir"); + await fs.mkdir(asDir); + const store = new FileSecretStore({ filePath: asDir }); + await expect(store.set("alpha", "env:A", "1")).rejects.toBeInstanceOf( + SecretStoreUnavailableError, + ); + }); + + it("a rejected set leaves the same store usable for later operations", async () => { + // The internal queue swallows failures on its chain so one rejection can't + // strand every subsequent call on that store — the returned promise still + // rejects, which is what the caller sees. + await writeEncryptedFixture(); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "wrong-key", + }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(); + expect(await store.get("alpha", "env:A")).toBe(null); + await expect(store.delete("alpha", "env:A")).resolves.toBeUndefined(); + await expect(store.set("alpha", "env:B", "2")).rejects.toThrow(); + }); + + it("delete is a no-op when the file exists but holds no matching entry", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + await store.delete("beta", "env:A"); + await store.deleteAllForServer("gamma"); + expect(await store.get("alpha", "env:A")).toBe("1"); + }); + + it("set reports an unreachable location instead of failing silently", async () => { + // A path whose parent is a *file*: the read fails with ENOTDIR rather + // than the ENOENT that means "no file yet", so this lands on the read + // refusal. It is the class the 503 exists for — fixable outside the + // process. + const blocker = path.join(tmpDir, "blocker"); + await fs.writeFile(blocker, "x", "utf-8"); + const store = new FileSecretStore({ + filePath: path.join(blocker, "secrets.json"), + }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( + /Could not read the secrets file/, + ); + }); + + // A read that succeeds (or reports "no file yet") followed by a write that + // fails — a read-only mount, a full disk, a permissions change since + // startup. Reproduced with a non-writable directory, which requires not + // being root: `chmod` is advisory to uid 0, so under root the write would + // succeed and the assertion would be wrong rather than merely unexercised. + // CI runs as an unprivileged user, which is where the coverage gate is + // enforced. + const asRoot = process.getuid?.() === 0; + it.skipIf(asRoot || process.platform === "win32")( + "set reports a write failure distinctly from a read failure", + async () => { + const dir = path.join(tmpDir, "locked"); + await fs.mkdir(dir); + await fs.chmod(dir, 0o555); + try { + const store = new FileSecretStore({ + filePath: path.join(dir, "secrets.json"), + }); + // Nothing is there to read, so the failure is unambiguously the write. + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( + /Could not write the secrets file/, + ); + } finally { + // Restore write permission or the afterEach `rm -rf` can't clean up. + await fs.chmod(dir, 0o755); + } + }, + ); + + it("a rejected set does not poison later sets on the same store", async () => { + // The internal queue swallows on the chain but not on the returned + // promise; without that, one failure would strand every subsequent write. + const blocked = path.join(tmpDir, "blocker"); + await fs.writeFile(blocked, "x", "utf-8"); + const bad = new FileSecretStore({ + filePath: path.join(blocked, "secrets.json"), + }); + await expect(bad.set("alpha", "env:A", "1")).rejects.toThrow(); + + // A different store on a good path still works, and so does a second + // write on the failing one's successor. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + await store.set("alpha", "env:B", "2"); + expect(await store.get("alpha", "env:A")).toBe("1"); + expect(await store.get("alpha", "env:B")).toBe("2"); + }); +}); + +describe("tightenSecretFilePermissions", () => { + it("repairs a loosened mode on an existing file", async () => { + if (process.platform === "win32") return; + await fs.writeFile(filePath(), "{}", "utf-8"); + await fs.chmod(filePath(), 0o644); + await tightenSecretFilePermissions(filePath()); + expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600); + }); + + it("is a no-op for a file that doesn't exist yet (the first-run case)", async () => { + await expect( + tightenSecretFilePermissions(path.join(tmpDir, "nope.json")), + ).resolves.toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts new file mode 100644 index 00000000..90b96bfe --- /dev/null +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -0,0 +1,384 @@ +/** + * The store-selection policy (#1950). + * + * The two questions this file exists to pin down are the ones a reviewer + * would want proof of, because getting either wrong silently changes where + * a user's secret goes: + * + * 1. an unreachable keychain falls back rather than failing, and says so; + * 2. the fallback is `memory` in a container with nothing durable + * mounted, and `file` otherwise. + * + * `resolveSecretStore` caches its answer for the process, so each case + * that exercises it goes through `vi.resetModules()` + a fresh dynamic + * import — the same technique the keyring-unloadable cases in + * `secret-store.test.ts` use, and for the same reason. The keychain probe + * is mocked because CI has no libsecret; the container and mount + * predicates are driven directly rather than through a real container, + * which is what makes them worth exporting. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + chooseFallbackKind, + isOnMountPoint, + parseSecretStoreEnv, + warnAboutSecretStorage, +} from "@inspector/core/auth/node/secret-store-selection.js"; +import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; + +const ENV_KEYS = [ + "MCP_INSPECTOR_SECRET_STORE", + "MCP_INSPECTOR_SECRET_FILE", + "MCP_INSPECTOR_SECRET_KEY", + "KUBERNETES_SERVICE_HOST", +]; + +let saved: Record<string, string | undefined>; +let tmpDir: string; + +beforeEach(async () => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "inspector-selection-")); +}); + +afterEach(async () => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** + * Load a fresh copy of the selection module with the keychain probe forced + * to a given answer. Fresh because the resolution is cached per module + * instance, which is exactly the behavior we don't want leaking between + * cases. + */ +async function loadWithProbe(available: boolean, detail = "no D-Bus session") { + vi.resetModules(); + vi.doMock("@inspector/core/auth/node/secret-store.js", async () => { + const actual = await vi.importActual< + typeof import("@inspector/core/auth/node/secret-store.js") + >("@inspector/core/auth/node/secret-store.js"); + return { + ...actual, + probeKeyringAvailable: vi.fn(async () => + available ? { available: true } : { available: false, detail }, + ), + }; + }); + return import("@inspector/core/auth/node/secret-store-selection.js"); +} + +describe("parseSecretStoreEnv", () => { + it("accepts each kind, case- and whitespace-insensitively", () => { + expect(parseSecretStoreEnv("keyring")).toBe("keyring"); + expect(parseSecretStoreEnv(" FILE ")).toBe("file"); + expect(parseSecretStoreEnv("Memory")).toBe("memory"); + }); + + it("treats unset and blank as unset", () => { + expect(parseSecretStoreEnv(undefined)).toBeUndefined(); + expect(parseSecretStoreEnv(" ")).toBeUndefined(); + }); + + it("warns and falls through on a typo rather than refusing to start", () => { + // A misspelled env var in a compose file should not be the reason the + // Inspector won't boot — the automatic policy is a working default. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(parseSecretStoreEnv("keychain")).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("keyring, file, memory"), + ); + }); +}); + +describe("chooseFallbackKind", () => { + it("uses memory in a container with nothing mounted", () => { + // The honest answer: a file in the writable layer is discarded by + // `docker run --rm` and by any image update, so promising durability + // there is worse than declining to. + expect(chooseFallbackKind({ container: true, mounted: false })).toBe( + "memory", + ); + }); + + it("uses file in a container once a volume is mounted", () => { + // Mounting the volume *is* the user saying this directory should + // survive, so no extra configuration should be needed to honor it. + expect(chooseFallbackKind({ container: true, mounted: true })).toBe("file"); + }); + + it("uses file outside a container, mounted or not", () => { + expect(chooseFallbackKind({ container: false, mounted: false })).toBe( + "file", + ); + expect(chooseFallbackKind({ container: false, mounted: true })).toBe( + "file", + ); + }); +}); + +describe("isContainer", () => { + /** + * Load the module with `node:fs` stubbed, so each detection signal can be + * exercised on a developer's laptop. The three exist because no single one + * covers the runtimes people use — Docker, Podman/containerd, and + * Kubernetes each announce themselves differently. + */ + async function loadWithFs(opts: { + dockerenv?: boolean; + cgroup?: string | Error; + }) { + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); + return { + ...actual, + existsSync: (p: string) => + p === "/.dockerenv" ? !!opts.dockerenv : actual.existsSync(p), + readFileSync: (p: string, enc?: unknown) => { + if (p === "/proc/1/cgroup") { + if (opts.cgroup instanceof Error) throw opts.cgroup; + if (opts.cgroup === undefined) throw new Error("ENOENT"); + return opts.cgroup; + } + return actual.readFileSync(p, enc as never); + }, + }; + }); + return import("@inspector/core/auth/node/secret-store-selection.js"); + } + + afterEach(() => { + vi.doUnmock("node:fs"); + vi.resetModules(); + }); + + it("is true inside Kubernetes, without touching the filesystem", async () => { + process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1"; + const mod = await loadWithFs({}); + expect(mod.isContainer()).toBe(true); + }); + + it("is true when /.dockerenv exists", async () => { + const mod = await loadWithFs({ dockerenv: true }); + expect(mod.isContainer()).toBe(true); + }); + + it("is true when a container runtime appears in /proc/1/cgroup", async () => { + const mod = await loadWithFs({ + cgroup: "0::/system.slice/containerd.service\n", + }); + expect(mod.isContainer()).toBe(true); + }); + + it("is false for a host cgroup line", async () => { + const mod = await loadWithFs({ + cgroup: "0::/user.slice/user-1000.slice\n", + }); + expect(mod.isContainer()).toBe(false); + }); + + it("is false when neither signal is readable (macOS, or no /proc)", async () => { + // Not a container as far as we know — and a false negative is the cheap + // direction, since it only biases the fallback between two working stores. + const mod = await loadWithFs({ cgroup: new Error("ENOENT") }); + expect(mod.isContainer()).toBe(false); + }); +}); + +describe("isOnMountPoint", () => { + it("is false for an ordinary directory", () => { + expect(isOnMountPoint(tmpDir)).toBe(false); + }); + + it("is false for a path that doesn't exist yet, walking to its parent", () => { + // The first-run shape: `~/.mcp-inspector` has not been created. Answering + // from the nearest existing ancestor is what keeps this from being a + // missing-file error at startup. + expect(isOnMountPoint(path.join(tmpDir, "nope", "deeper"))).toBe(false); + }); + + it("is false when the path itself can't be resolved", () => { + // A NUL byte makes `path.resolve` throw. Any failure to answer the + // question resolves to "not a mount", which keeps a container on the + // conservative side — memory, which never promises what it can't keep. + expect(isOnMountPoint("bad\0path")).toBe(false); + }); + + it("is false at the filesystem root", () => { + // `/` is its own parent and is trivially a mount — reporting true there + // would make every container look like it had a volume. + expect(isOnMountPoint(path.parse(process.cwd()).root)).toBe(false); + }); +}); + +describe("warnAboutSecretStorage", () => { + const base: SecretStorageInfo = { + kind: "keyring", + reason: "default", + durable: true, + }; + + it("says nothing for the ordinary keychain case", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage(base); + expect(warn).not.toHaveBeenCalled(); + }); + + it("announces a fallback, names the cause, and points at the override", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "memory", + reason: "fallback", + durable: false, + detail: "no D-Bus session", + }); + const output = warn.mock.calls.flat().join("\n"); + expect(output).toContain("OS keychain is not available"); + expect(output).toContain("no D-Bus session"); + expect(output).toContain("MCP_INSPECTOR_SECRET_STORE"); + // The lossy-store caveat rides along — this is the "loud" half of + // "automatic fallback with a loud banner". + expect(output).toContain("lost when the Inspector exits"); + }); + + it("warns about an unencrypted file even when it was explicitly chosen", () => { + // `reason: "configured"` means the user picked `file`; it does not mean + // they knew it would be unencrypted. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "file", + reason: "configured", + durable: true, + plaintext: true, + path: "/tmp/secrets.json", + }); + expect(warn.mock.calls.flat().join("\n")).toContain( + "MCP_INSPECTOR_SECRET_KEY", + ); + }); +}); + +describe("resolveSecretStore", () => { + it("uses the keychain when the probe succeeds", async () => { + const mod = await loadWithProbe(true); + const { info } = await mod.resolveSecretStore(); + expect(info).toMatchObject({ kind: "keyring", reason: "default" }); + }); + + it("caches the resolution so every consumer describes the same store", async () => { + // The banner, `/api/config`, and the store doing the writing are three + // readers of one decision; a re-probe that flipped would have the UI + // naming a store nobody is using. + const mod = await loadWithProbe(true); + expect(await mod.resolveSecretStore()).toBe(await mod.resolveSecretStore()); + }); + + it("falls back with a reason and a detail when the probe fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json"); + const mod = await loadWithProbe(false, "Couldn't access platform storage"); + const { info, store } = await mod.resolveSecretStore(); + expect(info.reason).toBe("fallback"); + expect(info.detail).toContain("Couldn't access platform storage"); + // And the store it hands back actually works — the point of falling back. + await store.set("alpha", "env:A", "1"); + expect(await store.get("alpha", "env:A")).toBe("1"); + }); + + it("honors an explicit memory store without probing", async () => { + process.env.MCP_INSPECTOR_SECRET_STORE = "memory"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const { info } = await mod.resolveSecretStore(); + expect(info).toMatchObject({ + kind: "memory", + reason: "configured", + durable: false, + }); + }); + + it("honors an explicit file store, reporting its path and encryption state", async () => { + const target = path.join(tmpDir, "custom", "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + process.env.MCP_INSPECTOR_SECRET_FILE = target; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const { info } = await mod.resolveSecretStore(); + expect(info).toMatchObject({ + kind: "file", + reason: "configured", + path: target, + plaintext: true, + durable: true, + }); + }); + + it("reports an encrypted file when the passphrase is set", async () => { + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + const mod = await loadWithProbe(true); + const { info } = await mod.resolveSecretStore(); + expect(info.plaintext).toBe(false); + }); + + it("getSecretStorageInfo returns the same descriptor as the resolution", async () => { + const mod = await loadWithProbe(true); + const { info } = await mod.resolveSecretStore(); + expect(await mod.getSecretStorageInfo()).toBe(info); + }); +}); + +describe("defaultSecretStore", () => { + it("delegates every operation to the resolved store", async () => { + // The deferred wrapper exists so the synchronous `?? defaultSecretStore()` + // call sites keep working; if it stopped forwarding, secrets would vanish + // into an object nobody reads. + process.env.MCP_INSPECTOR_SECRET_STORE = "memory"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const store = mod.defaultSecretStore(); + + await store.set("alpha", "env:A", "1"); + await store.set("alpha", "env:B", "2"); + await store.set("beta", "env:A", "3"); + expect(await store.get("alpha", "env:A")).toBe("1"); + + await store.delete("alpha", "env:A"); + expect(await store.get("alpha", "env:A")).toBe(null); + + await store.deleteAllForServer("alpha"); + expect(await store.get("alpha", "env:B")).toBe(null); + expect(await store.get("beta", "env:A")).toBe("3"); + + // And it is the *same* underlying store the resolution reports. + const { store: resolvedStore } = await mod.resolveSecretStore(); + expect(await resolvedStore.get("beta", "env:A")).toBe("3"); + }); +}); + +describe("defaultSecretFilePath", () => { + it("resolves a relative override to an absolute path", async () => { + // The path is shown in the UI footer, so a relative one would be + // ambiguous about which directory it is relative to. + process.env.MCP_INSPECTOR_SECRET_FILE = "./rel/secrets.json"; + const mod = await loadWithProbe(true); + expect(path.isAbsolute(mod.defaultSecretFilePath())).toBe(true); + }); + + it("defaults to ~/.mcp-inspector/secrets.json", async () => { + const mod = await loadWithProbe(true); + expect(mod.defaultSecretFilePath()).toContain( + path.join(".mcp-inspector", "secrets.json"), + ); + }); +}); diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index 2d66858e..a2e079ed 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -17,6 +17,7 @@ * its own describe built on `vi.resetModules()`. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describeSecretStoreContract } from "./secretStoreContract.js"; // The mock must be hoisted above the `await import` of secret-store // inside the `KeyringSecretStore` describe block. Use `vi.hoisted` so @@ -24,6 +25,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; const keyringMocks = vi.hoisted(() => { const password = new Map<string, string | null>(); const reset = () => password.clear(); + const storedAccounts = (): string[] => [...password.keys()]; // Behavior hooks each test can flip to simulate keychain unavailability // on specific operations. Defaults: real-ish in-memory behavior. const failures = { @@ -77,7 +79,14 @@ const keyringMocks = vi.hoisted(() => { if (failures.findThrows) throw new Error("keychain find unavailable"); return credentials(); }; - return { AsyncEntry, findCredentialsAsync, failures, password, reset }; + return { + AsyncEntry, + findCredentialsAsync, + failures, + password, + reset, + storedAccounts, + }; }); vi.mock("@napi-rs/keyring", () => ({ @@ -90,115 +99,20 @@ import { KeyringSecretStore, KeychainUnavailableError, KeyringModuleShapeError, + probeKeyringAvailable, SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, parseAccount, - type SecretStore, } from "@inspector/core/auth/node/secret-store.js"; -describe("InMemorySecretStore", () => { - let store: SecretStore; - - beforeEach(() => { - store = new InMemorySecretStore(); - }); - - it("returns null for a missing entry", async () => { - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - null, - ); - }); - - it("round-trips a value set then get", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - "shh", - ); - }); - - it("treats different server ids as separate namespaces", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "alpha-secret"); - await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret"); - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - "alpha-secret", - ); - expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - "beta-secret", - ); - }); - - it("treats different fields under the same server id as separate entries", async () => { - await store.set("alpha", envSecretField("API_KEY"), "k1"); - await store.set("alpha", envSecretField("DB_PASS"), "k2"); - expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1"); - expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2"); - }); - - it("overwrites an existing entry on set", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1"); - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2"); - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - "v2", - ); - }); - - it("delete is a no-op for a missing entry", async () => { - await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET); - // No throw, no state change. - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - null, - ); - }); - - it("delete removes only the targeted (id, field)", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); - await store.set("alpha", envSecretField("KEY"), "b"); - await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET); - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - null, - ); - expect(await store.get("alpha", envSecretField("KEY"))).toBe("b"); - }); - - it("deleteAllForServer removes every field under that id", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); - await store.set("alpha", envSecretField("KEY1"), "b"); - await store.set("alpha", envSecretField("KEY2"), "c"); - await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched"); - - await store.deleteAllForServer("alpha"); - - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - null, - ); - expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null); - expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null); - expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - "untouched", - ); - }); - - it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => { - // The account scheme is `${serverId}:${field}` — a literal prefix match - // would incorrectly sweep "alpha-prime" entries when deleting "alpha". - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); - await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p"); - - await store.deleteAllForServer("alpha"); - - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( - null, - ); - expect( - await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET), - ).toBe("p"); - }); - - it("round-trips an empty-string value (set + get returns '')", async () => { - await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, ""); - expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe(""); - }); -}); +// The generic cases live in `secretStoreContract.ts` and are shared with +// `FileSecretStore` (#1950) — a contract asserted against one implementation +// is not a contract. `KeyringSecretStore` keeps its own (failure-focused) +// block below, since it has no reachable backend in CI. +describeSecretStoreContract( + "InMemorySecretStore", + () => new InMemorySecretStore(), +); describe("parseAccount", () => { it("splits `${serverId}:${field}` on the first colon", () => { @@ -332,6 +246,22 @@ describe("KeyringSecretStore (mocked native bindings)", () => { ).toBe("p"); }); + it("deleteAllForServer skips an account it cannot parse", async () => { + // `findCredentialsAsync` returns whatever is in the keychain, including + // an entry this build didn't write. An empty field yields the account + // "alpha:", which `parseAccount` rejects — the guard is what keeps a + // stray entry from being treated as belonging to this server. + await store.set("alpha", "", "orphan"); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "real"); + + await store.deleteAllForServer("alpha"); + + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + expect(await store.get("alpha", "")).toBe("orphan"); + }); + it("KeychainUnavailableError stringifies a non-Error cause", () => { const err = new KeychainUnavailableError("plain string cause"); expect(err).toBeInstanceOf(KeychainUnavailableError); @@ -447,6 +377,44 @@ describe("KeyringSecretStore (mocked native bindings)", () => { await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); }); }); + + describe("probeKeyringAvailable (#1950)", () => { + // The input to the automatic-fallback policy. It has to answer "is the + // keychain *usable*", not "did the package import" — the container that + // motivated #1848 imports the package fine and only fails when + // `AsyncEntry::new` reaches for a Secret Service that isn't there. + it("reports available when a read round-trips", async () => { + await expect(probeKeyringAvailable()).resolves.toEqual({ + available: true, + }); + }); + + it("reports unavailable, with the cause, when construction throws", async () => { + keyringMocks.failures.constructorThrows = true; + const result = await probeKeyringAvailable(); + expect(result.available).toBe(false); + // The detail is what the fallback banner prints, and the three + // realistic causes need different fixes — so it is surfaced rather + // than summarized. + expect(result).toHaveProperty("detail"); + expect((result as { detail: string }).detail).toContain( + "Couldn't access platform storage", + ); + }); + + it("reports unavailable when the read itself throws", async () => { + keyringMocks.failures.getThrows = true; + expect((await probeKeyringAvailable()).available).toBe(false); + }); + + it("never writes to the user's keychain", async () => { + // Deliberate: a write probe would be a stronger signal but would + // deposit a value in someone's login keyring at every startup, for a + // store they may never use. + await probeKeyringAvailable(); + expect(keyringMocks.storedAccounts()).toEqual([]); + }); + }); }); describe("@napi-rs/keyring unloadable on this platform (#1905)", () => { @@ -539,6 +507,21 @@ describe("@napi-rs/keyring unloadable on this platform (#1905)", () => { await expect(store.deleteAllForServer("alpha")).resolves.toBeUndefined(); }); + it("the availability probe reports unavailable and names the load error", async () => { + // The fallback policy's input on Android/Termux: nothing to probe, so + // the answer must come from the load failure rather than from an + // attempted construction that would throw somewhere less legible. + const mod = await importWithUnloadableKeyring(); + const result = await mod.probeKeyringAvailable(); + expect(result.available).toBe(false); + // The detail carries the loader's own message. Asserted as non-empty + // rather than by content: vitest replaces whatever a throwing `doMock` + // factory threw with its own "error when mocking a module" text (see the + // hint-branch note above), so the literal `Cannot find module` string + // never reaches us here even though it does in production. + expect((result as { detail: string }).detail).not.toBe(""); + }); + it("attempts the load once and caches the failure", async () => { // Without the cache every secret operation re-attempts (and re-throws) // the resolution — `expectedSecretFields` means that is once per diff --git a/clients/web/src/test/integration/auth/node/secretStoreContract.ts b/clients/web/src/test/integration/auth/node/secretStoreContract.ts new file mode 100644 index 00000000..e92079a3 --- /dev/null +++ b/clients/web/src/test/integration/auth/node/secretStoreContract.ts @@ -0,0 +1,146 @@ +/** + * The behavioral contract every `SecretStore` implementation must satisfy, + * as a reusable block of `it()`s. + * + * These assertions were written once against `InMemorySecretStore` and are + * now shared with `FileSecretStore` (#1950), which is the point: the whole + * design rests on callers not knowing which store they got, and a contract + * asserted against only one implementation cannot support that claim. The + * cases that matter most are the two that look like edge cases and are + * not — the empty-string value (a real value that must round-trip, which + * is why the interface returns `null` for "absent" rather than falsy) and + * the shared-prefix server id (`alpha` vs `alpha-prime`, where a literal + * prefix sweep in `deleteAllForServer` deletes the wrong server's + * secrets). + * + * `KeyringSecretStore` is deliberately *not* run through this block: it + * has no reachable backend in CI, so its own file drives a mocked native + * layer and asserts the failure half of the contract instead. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import type { SecretStore } from "@inspector/core/auth/node/secret-store.js"; +import { + SECRET_FIELD_OAUTH_CLIENT_SECRET, + envSecretField, +} from "@inspector/core/auth/secret-fields.js"; + +/** + * @param name Suite label. + * @param makeStore Produces a *fresh, empty* store. Called per test — a + * file-backed implementation uses this to point at a new temp path, so + * the shared cases stay ignorant of where the state lives. + */ +export function describeSecretStoreContract( + name: string, + makeStore: () => SecretStore | Promise<SecretStore>, +): void { + describe(`${name} (SecretStore contract)`, () => { + let store: SecretStore; + + beforeEach(async () => { + store = await makeStore(); + }); + + it("returns null for a missing entry", async () => { + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + }); + + it("round-trips a value set then get", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "shh", + ); + }); + + it("treats different server ids as separate namespaces", async () => { + await store.set( + "alpha", + SECRET_FIELD_OAUTH_CLIENT_SECRET, + "alpha-secret", + ); + await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "beta-secret"); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "alpha-secret", + ); + expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "beta-secret", + ); + }); + + it("treats different fields under the same server id as separate entries", async () => { + await store.set("alpha", envSecretField("API_KEY"), "k1"); + await store.set("alpha", envSecretField("DB_PASS"), "k2"); + expect(await store.get("alpha", envSecretField("API_KEY"))).toBe("k1"); + expect(await store.get("alpha", envSecretField("DB_PASS"))).toBe("k2"); + }); + + it("overwrites an existing entry on set", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v1"); + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "v2"); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "v2", + ); + }); + + it("delete is a no-op for a missing entry", async () => { + await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET); + // No throw, no state change. + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + }); + + it("delete removes only the targeted (id, field)", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); + await store.set("alpha", envSecretField("KEY"), "b"); + await store.delete("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + expect(await store.get("alpha", envSecretField("KEY"))).toBe("b"); + }); + + it("deleteAllForServer removes every field under that id", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); + await store.set("alpha", envSecretField("KEY1"), "b"); + await store.set("alpha", envSecretField("KEY2"), "c"); + await store.set("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET, "untouched"); + + await store.deleteAllForServer("alpha"); + + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + expect(await store.get("alpha", envSecretField("KEY1"))).toBe(null); + expect(await store.get("alpha", envSecretField("KEY2"))).toBe(null); + expect(await store.get("beta", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "untouched", + ); + }); + + it("deleteAllForServer does not delete entries on a different id that happens to share a prefix", async () => { + // The account scheme is `${serverId}:${field}` — a literal prefix match + // would incorrectly sweep "alpha-prime" entries when deleting "alpha". + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "a"); + await store.set("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET, "p"); + + await store.deleteAllForServer("alpha"); + + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + null, + ); + expect( + await store.get("alpha-prime", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).toBe("p"); + }); + + it("round-trips an empty-string value (set + get returns '')", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, ""); + expect(await store.get("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET)).toBe( + "", + ); + }); + }); +} diff --git a/clients/web/src/test/integration/server/web-server-config.test.ts b/clients/web/src/test/integration/server/web-server-config.test.ts index 7dbb2e4a..df51e8ab 100644 --- a/clients/web/src/test/integration/server/web-server-config.test.ts +++ b/clients/web/src/test/integration/server/web-server-config.test.ts @@ -583,6 +583,28 @@ describe("webServerConfigToInitialPayload", () => { expect(payload.defaultTransport).toBe("streamable-http"); expect(payload.defaultServerUrl).toBe("https://srv/other"); }); + + it("omits secretStorage when the caller didn't resolve a store", () => { + // The UI treats an absent descriptor as "unknown" and shows nothing. A + // defaulted value would put a confident wrong answer under a secret field. + expect(webServerConfigToInitialPayload(baseConfig()).secretStorage).toBe( + undefined, + ); + }); + + it("passes the resolved store through verbatim (#1950)", () => { + const secretStorage = { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + } as const; + expect( + webServerConfigToInitialPayload(baseConfig(), secretStorage) + .secretStorage, + ).toEqual(secretStorage); + }); }); describe("printServerBanner", () => { @@ -668,6 +690,38 @@ describe("printServerBanner", () => { printServerBanner(withAuto, 6274, "tok", undefined); expect(logSpy.lines.some((l) => l.includes("Opening browser"))).toBe(true); }); + + it("prints the secret store for every kind, not only the alarming ones", () => { + // A line that appears only when something is wrong can't be used to + // confirm that nothing is. + printServerBanner(baseConfig(), 6274, "tok", undefined, { + kind: "keyring", + reason: "default", + durable: true, + }); + expect(logSpy.lines.some((l) => l.includes("Secrets: OS keychain"))).toBe( + true, + ); + }); + + it("names the path and the fallback for a file-backed store", () => { + printServerBanner(baseConfig(), 6274, "tok", undefined, { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + }); + const line = logSpy.lines.find((l) => l.includes("Secrets:")); + expect(line).toContain("File (unencrypted)"); + expect(line).toContain("/home/node/.mcp-inspector/secrets.json"); + expect(line).toContain("fell back"); + }); + + it("prints no secrets line when no store was resolved", () => { + printServerBanner(baseConfig(), 6274, "tok", undefined); + expect(logSpy.lines.some((l) => l.includes("Secrets:"))).toBe(false); + }); }); // Minimal console.log capture so banner-format assertions don't pollute stdout. diff --git a/clients/web/src/theme/Group.ts b/clients/web/src/theme/Group.ts index de6623cf..d6e5b0df 100644 --- a/clients/web/src/theme/Group.ts +++ b/clients/web/src/theme/Group.ts @@ -13,4 +13,32 @@ export const ThemeGroup = Group.extend({ if (props.variant === "sectionHeader") return { root: "filter-toggle" }; return {}; }, + styles: (_theme, props) => { + // `secretStorageFooter` is the permanent band at the bottom of the + // Client/Server Settings modals naming where secrets are stored (#1950). + // Flat properties, so they belong here rather than in App.css. + // + // `position: sticky` is the load-bearing part, and it is the mirror of how + // Mantine pins the modal header. `Modal.Content` wraps *all* of its + // children — header, body, and this band — in one scroll area sized to the + // viewport, so a plain block at the end simply scrolls off the bottom with + // the body (measured: at 900px tall the band landed 140px below the fold). + // A footer the user has to scroll to find is not the permanent statement + // this is supposed to be, so it sticks to the bottom edge instead, with + // the same opaque body background and z-index the sticky header uses so + // content passes underneath rather than through it. + if (props.variant === "secretStorageFooter") { + return { + root: { + position: "sticky", + bottom: 0, + zIndex: 1000, + backgroundColor: "var(--mantine-color-body)", + borderTop: "1px solid var(--inspector-border-default)", + flexShrink: 0, + }, + }; + } + return { root: {} }; + }, }); diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts new file mode 100644 index 00000000..a61e2fc2 --- /dev/null +++ b/core/auth/node/file-secret-store.ts @@ -0,0 +1,400 @@ +/** + * File-backed {@link SecretStore} — the persistence answer for a box with + * no reachable OS keychain (#1950): the published container (no D-Bus + * session, #1848), Android/Termux (no platform binary, #1905), and a + * minimal Linux install without libsecret. + * + * **This partially reverses #1356 on purpose.** That change moved + * `oauthClientSecret` *out of* `mcp.json` and into the keychain to get it + * off disk. Writing secrets back to disk here is therefore a decision, not + * an oversight, and it is bounded three ways: the file is separate from + * `mcp.json` (so a user pasting their catalog into an issue does not paste + * their secrets), it is written `0600`, and it is encrypted whenever + * `MCP_INSPECTOR_SECRET_KEY` is set. What it buys is the alternative: + * before this, those users could not persist a secret at all — `set` threw + * and the route answered 503. + * + * **Format.** One JSON document, whose whole secret map is encrypted as a + * unit rather than value-by-value: + * + * ```json + * { "version": 1, "encryption": "none", "secrets": { "srv:field": "s3cr3t" } } + * { "version": 1, "encryption": "aes-256-gcm", + * "kdf": { "algorithm": "scrypt", "salt": "…", "N": 16384, "r": 8, "p": 1 }, + * "data": "<iv>.<authTag>.<ciphertext>" } + * ``` + * + * Encrypting the map as a unit — not each value — is what hides the + * *account names* too. Those are `${serverId}:${field}`, so a per-value + * scheme would leave a readable index of which servers you use and which + * of them you hold an OAuth client secret for. That index is worth + * roughly as much to an attacker as some of the values. + * + * **Key.** `MCP_INSPECTOR_SECRET_KEY` is a passphrase, not a key: it is + * stretched with scrypt against a per-file random salt stored beside the + * ciphertext. That means a short, memorable value is safe to use, and it + * means the same passphrase produces a different key for a different file. + * + * **Availability contract — identical to `KeyringSecretStore`'s**, because + * callers must not have to know which store they got. `get` is tolerant + * (returns `null` on any failure), `delete` no-ops, and `set` is the only + * operation that hard-fails, throwing {@link SecretStoreUnavailableError} + * — the moment where a value would actually be lost. Routes translate that + * to a 503 the same way they already do for the keychain. + * + * The one failure worth calling out is the *key mismatch*: a file written + * encrypted, opened later with the passphrase changed or removed. Reads go + * quiet (`null`, per the contract) but writes must not — writing would + * replace a file full of still-valid secrets with one holding a single new + * one, destroying data to satisfy a request that was only ever additive. + * So `set` refuses and says which of the two things to fix. + */ + +import * as crypto from "node:crypto"; +import * as fs from "node:fs/promises"; +import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; +import { + SecretStoreUnavailableError, + type SecretStore, +} from "./secret-store.js"; + +/** + * Promisified `scrypt`. Hand-wrapped rather than `promisify`d because the + * overload carrying the cost parameters is not the one `promisify`'s types + * pick up, and the alternative — `scryptSync` — blocks the event loop for + * the duration of the derivation on every read and write. + */ +const scrypt = ( + passphrase: string, + salt: Buffer, + keylen: number, + options: crypto.ScryptOptions, +): Promise<Buffer> => + new Promise((resolve, reject) => { + crypto.scrypt(passphrase, salt, keylen, options, (err, key) => { + /* v8 ignore next -- @preserve: node validates the cost parameters + synchronously (an out-of-grammar `N` throws from the call itself, + which the corrupt-KDF test exercises), so this asynchronous channel + is left for allocation failure — not provokable deterministically. */ + if (err) reject(err); + else resolve(key); + }); + }); + +const FORMAT_VERSION = 1; +const CIPHER = "aes-256-gcm"; +const KEY_BYTES = 32; +const IV_BYTES = 12; +/** scrypt defaults. `N` is the only one that meaningfully costs time; 2^14 + * derives in a few milliseconds, which is invisible next to the file I/O it + * accompanies and is derived once per read/write rather than per secret. */ +const SCRYPT_N = 16384; +const SCRYPT_R = 8; +const SCRYPT_P = 1; + +/** Env var holding the passphrase. Absent → the file is written in the clear. */ +export const SECRET_KEY_ENV = "MCP_INSPECTOR_SECRET_KEY"; + +interface KdfParams { + algorithm: "scrypt"; + salt: string; + N: number; + r: number; + p: number; +} + +interface SecretFile { + version: number; + encryption: "none" | "aes-256-gcm"; + kdf?: KdfParams; + secrets?: Record<string, string>; + data?: string; +} + +const buildAccount = (serverId: string, field: string): string => + `${serverId}:${field}`; + +/** + * Thrown when a file was written encrypted and the current passphrase + * cannot open it. A distinct type so `set` can refuse with advice that + * fits, instead of the generic "could not write" that would send someone + * looking at permissions. + */ +export class SecretFileKeyMismatchError extends SecretStoreUnavailableError { + constructor(filePath: string, hasKey: boolean) { + super( + hasKey + ? `The secrets file at ${filePath} could not be decrypted with the current ${SECRET_KEY_ENV}. Refusing to write, which would overwrite the existing secrets. Restore the original passphrase, or delete the file to start over.` + : `The secrets file at ${filePath} is encrypted but ${SECRET_KEY_ENV} is not set. Refusing to write, which would overwrite the existing secrets. Set the passphrase this file was written with, or delete the file to start over.`, + ); + this.name = "SecretFileKeyMismatchError"; + } +} + +const encodeParts = (iv: Buffer, tag: Buffer, body: Buffer): string => + `${iv.toString("base64")}.${tag.toString("base64")}.${body.toString("base64")}`; + +/** + * Split a `<iv>.<tag>.<ciphertext>` payload, rejecting anything that is not + * exactly three parts. `split(".")` on a truncated or extended payload + * otherwise yields buffers of the wrong length, and `createDecipheriv` + * reports that as a generic error further away from the cause. + */ +function decodeParts( + payload: string, +): { iv: Buffer; tag: Buffer; body: Buffer } | null { + const parts = payload.split("."); + if (parts.length !== 3) return null; + const [iv, tag, body] = parts as [string, string, string]; + return { + iv: Buffer.from(iv, "base64"), + tag: Buffer.from(tag, "base64"), + body: Buffer.from(body, "base64"), + }; +} + +export interface FileSecretStoreOptions { + /** Absolute path of the secrets file. */ + filePath: string; + /** + * Passphrase. Defaults to `process.env[SECRET_KEY_ENV]`; an empty or + * whitespace-only value counts as absent, since `MCP_INSPECTOR_SECRET_KEY=` + * in a compose file is a user who meant "off", not a one-character key. + */ + passphrase?: string; +} + +export class FileSecretStore implements SecretStore { + readonly filePath: string; + private readonly passphrase: string | undefined; + /** + * Serializes read-modify-write cycles within this process. `writeStoreFile` + * already makes each individual write atomic and ordered, but that is not + * enough here: two concurrent `set`s would each read the same "before" map + * and the second write would drop the first one's entry. The route handlers + * write a server's fields with `Promise.all`, so this is the ordinary path, + * not a race that needs contriving. + */ + private queue: Promise<unknown> = Promise.resolve(); + + constructor(options: FileSecretStoreOptions) { + this.filePath = options.filePath; + const raw = options.passphrase ?? process.env[SECRET_KEY_ENV]; + this.passphrase = raw && raw.trim() ? raw : undefined; + } + + /** True when this store encrypts. Drives the `plaintext` descriptor flag. */ + get encrypted(): boolean { + return this.passphrase !== undefined; + } + + private async deriveKey(kdf: KdfParams): Promise<Buffer> { + return scrypt( + // Only reached when the passphrase is set — every caller checks first, + // which is what makes the non-null assertion safe here rather than a + // widened `string | undefined` threaded through the crypto helpers. + this.passphrase as string, + Buffer.from(kdf.salt, "base64"), + KEY_BYTES, + { N: kdf.N, r: kdf.r, p: kdf.p }, + ); + } + + /** + * Read and decode the file into a plain map. + * + * Distinguishes three outcomes that callers treat differently: `null` for + * "no file yet" (a first write should create one), a map for success, and a + * throw for "there is a file and we cannot read it" — which must never be + * flattened into an empty map, or `set` would cheerfully overwrite it. + */ + private async readMap(): Promise<Record<string, string> | null> { + const raw = await readStoreFile(this.filePath); + if (raw === null) return null; + + let parsed: SecretFile; + try { + parsed = JSON.parse(raw) as SecretFile; + } catch (err) { + throw new SecretStoreUnavailableError( + `The secrets file at ${this.filePath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (parsed.encryption === "none") { + // A plaintext file read by a store that now has a passphrase is fine and + // deliberately not an error: the next write re-encrypts it, so setting + // the env var upgrades an existing file in place. + return { ...(parsed.secrets ?? {}) }; + } + + if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) { + throw new SecretStoreUnavailableError( + `The secrets file at ${this.filePath} declares an unsupported format (encryption="${String(parsed.encryption)}", version=${String(parsed.version)}). It may have been written by a newer Inspector.`, + ); + } + if (!this.passphrase) + throw new SecretFileKeyMismatchError(this.filePath, false); + + const parts = decodeParts(parsed.data); + if (!parts) throw new SecretFileKeyMismatchError(this.filePath, true); + try { + const key = await this.deriveKey(parsed.kdf); + const decipher = crypto.createDecipheriv(CIPHER, key, parts.iv); + decipher.setAuthTag(parts.tag); + const plain = Buffer.concat([ + decipher.update(parts.body), + decipher.final(), + ]).toString("utf-8"); + return JSON.parse(plain) as Record<string, string>; + } catch { + // GCM authentication failure is what a wrong passphrase looks like, and + // it is indistinguishable from a tampered/corrupt file — both mean "this + // key does not open this file", which is what the message says. + throw new SecretFileKeyMismatchError(this.filePath, true); + } + } + + private async writeMap(map: Record<string, string>): Promise<void> { + let file: SecretFile; + if (this.passphrase) { + const kdf: KdfParams = { + algorithm: "scrypt", + // A fresh salt per write, not per file: rewriting with the same salt + // and a fresh IV would still be safe for GCM, but re-salting costs one + // scrypt derivation we were doing anyway and removes the question. + salt: crypto.randomBytes(16).toString("base64"), + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + }; + const key = await this.deriveKey(kdf); + const iv = crypto.randomBytes(IV_BYTES); + const cipher = crypto.createCipheriv(CIPHER, key, iv); + const body = Buffer.concat([ + cipher.update(JSON.stringify(map), "utf-8"), + cipher.final(), + ]); + file = { + version: FORMAT_VERSION, + encryption: CIPHER, + kdf, + data: encodeParts(iv, cipher.getAuthTag(), body), + }; + } else { + file = { version: FORMAT_VERSION, encryption: "none", secrets: map }; + } + // `writeStoreFile` creates the parent dir, writes atomically, and sets + // 0600 — the same helper the OAuth token store uses, so the two files on + // disk have identical permissions by construction rather than by + // convention. + await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`); + } + + /** Run `fn` with the file to itself, so concurrent callers can't lose writes. */ + private serialize<T>(fn: () => Promise<T>): Promise<T> { + const run = this.queue.then(fn); + // Swallow on the chain only — the returned promise still rejects, so a + // failed `set` surfaces to its caller while a later `set` is not poisoned + // by it. This assignment is also why `this.queue` needs no `.catch()` of + // its own above: it is only ever `Promise.resolve()` or an + // already-swallowed promise, so it cannot reject. + this.queue = run.then( + () => {}, + () => {}, + ); + return run; + } + + async get(serverId: string, field: string): Promise<string | null> { + try { + const map = await this.serialize(() => this.readMap()); + return map?.[buildAccount(serverId, field)] ?? null; + } catch { + // Read tolerance, matching `KeyringSecretStore.get`: an unreadable store + // and an absent entry are the same answer to the caller, and hard-failing + // here would break every `GET /api/servers` on a box whose passphrase + // changed. + return null; + } + } + + async set(serverId: string, field: string, value: string): Promise<void> { + await this.serialize(async () => { + let map: Record<string, string>; + try { + map = (await this.readMap()) ?? {}; + } catch (err) { + // Already a typed refusal (unreadable file / key mismatch) — rethrow + // rather than re-wrapping, so the specific advice survives. + if (err instanceof SecretStoreUnavailableError) throw err; + throw new SecretStoreUnavailableError( + `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + map[buildAccount(serverId, field)] = value; + try { + await this.writeMap(map); + } catch (err) { + throw new SecretStoreUnavailableError( + `Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }); + } + + async delete(serverId: string, field: string): Promise<void> { + await this.deleteWhere((key) => key === buildAccount(serverId, field)); + } + + async deleteAllForServer(serverId: string): Promise<void> { + const prefix = `${serverId}:`; + await this.deleteWhere((key) => key.startsWith(prefix)); + } + + /** + * Shared delete body. Silent on every failure, matching the keyring store: + * every reason a delete can fail collapses to "the entry isn't there + * anymore", and `set` is the operation that reports a broken store. + */ + private async deleteWhere(match: (key: string) => boolean): Promise<void> { + try { + await this.serialize(async () => { + const map = await this.readMap(); + if (!map) return; + const keys = Object.keys(map).filter(match); + if (keys.length === 0) return; + for (const key of keys) delete map[key]; + // An emptied map keeps the file rather than unlinking it: its presence + // is what records which encryption mode this install settled on, so a + // later write can't silently switch modes on the user. + await this.writeMap(map); + }); + } catch { + // Intentionally silent — see the doc comment above. + } + } +} + +/** + * Best-effort permission repair for a pre-existing secrets file. + * + * `writeStoreFile` passes `mode: 0o600`, but a mode argument only applies + * when the file is *created* — an atomic rename over a file someone made + * `0644` by hand (or restored from a backup, or copied out of an image) + * keeps the loose mode forever. Called once at store selection so the + * guarantee the docs make is the guarantee on disk. + * + * Failures are swallowed: a file we cannot chmod is one we probably cannot + * write either, and `set` is where that gets reported with context. + */ +export async function tightenSecretFilePermissions( + filePath: string, +): Promise<void> { + try { + await fs.chmod(filePath, 0o600); + } catch { + // Non-existent (the common case — nothing written yet), read-only mount, + // or a platform without POSIX modes. Nothing actionable here. + } +} diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts new file mode 100644 index 00000000..9f07d903 --- /dev/null +++ b/core/auth/node/secret-store-selection.ts @@ -0,0 +1,323 @@ +/** + * Which {@link SecretStore} this process uses, and how the user finds out + * (#1950). + * + * ## The policy + * + * `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. With it + * unset: + * + * 1. Probe the OS keychain. Reachable → use it. This is the overwhelming + * majority of runs and nothing changes for them. + * 2. Unreachable → fall back, **loudly**. Which way we fall back depends + * on whether the file we would write is going to survive: + * - in a container whose secrets directory is *not* on a mount → + * `memory`; + * - otherwise → `file`. + * + * ## Why automatic + * + * Automatic fallback silently changes where a secret lives, which is a + * real objection to make of a security-adjacent tool. But the objection + * is to *silently*, not to *automatic*: requiring an env var before any + * persistence works at all means the container user's first encounter is + * a 503 with no hint that a working configuration exists — and the store + * they would then be told to set is the one this code would have picked + * anyway. So we take the friendly default and pay for it in noise. The + * downgrade prints a banner, rides `GET /api/config`, and is named in the + * footer of both settings modals — permanently, where the secret is + * typed, rather than once at startup where the person typing it 20 + * minutes later will never see it. + * + * ## Why memory can be the better answer + * + * A file in a container's writable layer looks like persistence and isn't: + * `docker run --rm` discards it, and so does any image update. An + * in-memory store makes no such promise, and says so. Mounting a volume — + * which the README already recommends for the catalog — flips the same + * run to `file` with no configuration, because the mount is the user + * stating that this directory is meant to survive. + */ + +import * as fsSync from "node:fs"; +import * as path from "node:path"; +import { + type SecretStorageInfo, + type SecretStoreKind, + secretStorageCaveat, + secretStorageSummary, +} from "../secret-storage-info.js"; +import { + FileSecretStore, + tightenSecretFilePermissions, +} from "./file-secret-store.js"; +import { + InMemorySecretStore, + KeyringSecretStore, + probeKeyringAvailable, + type SecretStore, +} from "./secret-store.js"; + +/** Env var naming an explicit store, bypassing the probe entirely. */ +export const SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE"; + +/** Env var overriding the secrets file location. */ +export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE"; + +const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"]; + +export interface ResolvedSecretStore { + store: SecretStore; + info: SecretStorageInfo; +} + +/** + * Parse {@link SECRET_STORE_ENV}. An unrecognized value returns undefined + * (falling through to the automatic policy) after a warning rather than + * throwing: a typo in a compose file should not stop the Inspector from + * starting, and the user needs the correct spellings more than a stack. + */ +export function parseSecretStoreEnv( + raw: string | undefined, +): SecretStoreKind | undefined { + const value = raw?.trim().toLowerCase(); + if (!value) return undefined; + if ((KINDS as string[]).includes(value)) return value as SecretStoreKind; + console.warn( + `[mcp-inspector] Ignoring ${SECRET_STORE_ENV}="${raw}": expected one of ${KINDS.join(", ")}.`, + ); + return undefined; +} + +/** Default secrets file: beside the catalog, in `~/.mcp-inspector`. */ +export function defaultSecretFilePath(): string { + const override = process.env[SECRET_FILE_ENV]?.trim(); + if (override) return path.resolve(override); + const homeDir = process.env.HOME || process.env.USERPROFILE || "."; + return path.join(homeDir, ".mcp-inspector", "secrets.json"); +} + +/** + * Are we in a container? + * + * Three signals, any of which is sufficient, because no single one covers + * the runtimes people actually use: `/.dockerenv` is Docker-specific and + * absent under Podman and containerd; the `/proc/1/cgroup` scan catches + * those but reads like the host's under cgroup v2 on some setups; and + * `KUBERNETES_SERVICE_HOST` catches a pod regardless of runtime. + * + * A false positive here is cheap and a false negative is cheaper still — + * this only *biases the fallback* between two working stores, and the + * mount check below is what actually decides. Nothing about correctness + * rests on getting this exactly right, which is why it is allowed to be a + * heuristic at all. + */ +export function isContainer(): boolean { + if (process.env.KUBERNETES_SERVICE_HOST) return true; + try { + if (fsSync.existsSync("/.dockerenv")) return true; + } catch { + // A sandboxed / restricted filesystem. Fall through to the cgroup read. + } + try { + const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8"); + return /\b(docker|containerd|kubepods|podman|lxc)\b/.test(cgroup); + } catch { + // Not Linux, or /proc is not mounted. Not a container as far as we know. + return false; + } +} + +/** + * Is `dir` — or the nearest existing ancestor of it — a mount point? + * + * The test is `st_dev`: a mount is exactly the case where a directory sits + * on a different device from its parent. That is what makes this work + * regardless of *what* was mounted (named volume, bind mount, tmpfs) and + * without parsing `/proc/mounts`, whose format and container-namespace + * visibility vary. + * + * It walks to the nearest existing ancestor because the directory usually + * does not exist yet on a first run — `~/.mcp-inspector` is created on + * demand. A volume mounted *at* that path exists by definition, so the + * common positive case is found immediately; the walk is what keeps the + * common negative case from being answered by a missing-file error. + * + * `/` is its own parent and is trivially a "mount", so the loop stops + * there and reports false: the container's root filesystem is the writable + * layer this whole check exists to distinguish *against*. + */ +export function isOnMountPoint(dir: string): boolean { + try { + let current = path.resolve(dir); + // Walk up to the first path that exists. + while (!fsSync.existsSync(current)) { + const parent = path.dirname(current); + /* v8 ignore next -- @preserve: the walk can only run out of ancestors + if the filesystem root itself does not exist, which would mean the + process has no cwd to have resolved against. Kept as the loop's + termination guard rather than trusting that invariant. */ + if (parent === current) return false; + current = parent; + } + const parent = path.dirname(current); + if (parent === current) return false; // reached `/` + return fsSync.statSync(current).dev !== fsSync.statSync(parent).dev; + } catch { + // Permission denied, a racing unmount, or a platform where `dev` is not + // meaningful. "Not a mount" keeps the fallback on the conservative side + // (memory in a container), which never loses a secret it promised to keep. + return false; + } +} + +/** Build the store named by `kind`, plus the descriptor that explains it. */ +function buildStore( + kind: SecretStoreKind, + reason: SecretStorageInfo["reason"], + detail?: string, +): ResolvedSecretStore { + if (kind === "keyring") { + return { + store: new KeyringSecretStore(), + info: { kind, reason, durable: true }, + }; + } + if (kind === "memory") { + return { + store: new InMemorySecretStore(), + info: { kind, reason, durable: false, detail }, + }; + } + const filePath = defaultSecretFilePath(); + const store = new FileSecretStore({ filePath }); + // Repair a loosened mode on a pre-existing file before anything reads or + // writes it. Fire-and-forget: it is a best-effort tightening (it swallows + // every failure), and blocking selection on a chmod would make an + // unwritable file a startup problem rather than a `set`-time one. + void tightenSecretFilePermissions(filePath); + return { + store, + info: { + kind, + reason, + path: filePath, + plaintext: !store.encrypted, + durable: true, + detail, + }, + }; +} + +/** + * Pick the fallback store for a host with no keychain. Exported for the + * tests, which drive the container/mount predicates directly rather than + * trying to make a real container appear. + */ +export function chooseFallbackKind(opts: { + container: boolean; + mounted: boolean; +}): SecretStoreKind { + return opts.container && !opts.mounted ? "memory" : "file"; +} + +/** + * Print the fallback / plaintext warnings. + * + * `console.warn` rather than the pino file logger deliberately: the person + * who needs this is watching a terminal or `docker logs`, and store + * selection happens before (and independently of) any logger being + * configured. + */ +export function warnAboutSecretStorage(info: SecretStorageInfo): void { + if (info.reason === "fallback") { + console.warn( + `\n[mcp-inspector] The OS keychain is not available, so secrets will be kept in: ${secretStorageSummary(info)}.` + + (info.detail + ? `\n[mcp-inspector] Keychain error: ${info.detail}` + : "") + + `\n[mcp-inspector] Set ${SECRET_STORE_ENV}=keyring|file|memory to choose explicitly.`, + ); + } + const caveat = secretStorageCaveat(info); + if (caveat) console.warn(`[mcp-inspector] ${caveat}`); +} + +let resolved: Promise<ResolvedSecretStore> | undefined; + +/** + * Resolve the active store, once per process. + * + * Cached because the probe is a native round-trip and, more importantly, + * because the answer must not differ between two callers: the banner, the + * `/api/config` payload, and the store actually doing the writing are + * three consumers of one decision, and a re-probe that flipped would have + * the UI describing a store nobody is using. There is no reset seam for + * the same reason a platform does not grow a keychain mid-run; tests get a + * fresh module (and so a fresh cache) via `vi.resetModules()`. + */ +export function resolveSecretStore(): Promise<ResolvedSecretStore> { + resolved ??= (async (): Promise<ResolvedSecretStore> => { + const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]); + if (configured) { + const result = buildStore(configured, "configured"); + warnAboutSecretStorage(result.info); + return result; + } + const probe = await probeKeyringAvailable(); + if (probe.available) return buildStore("keyring", "default"); + + const kind = chooseFallbackKind({ + container: isContainer(), + mounted: isOnMountPoint(path.dirname(defaultSecretFilePath())), + }); + const result = buildStore(kind, "fallback", probe.detail); + warnAboutSecretStorage(result.info); + return result; + })(); + return resolved; +} + +/** The active store's descriptor — for the banner and `GET /api/config`. */ +export async function getSecretStorageInfo(): Promise<SecretStorageInfo> { + return (await resolveSecretStore()).info; +} + +/** + * A `SecretStore` that resolves the real one on first use. + * + * Selection is async (it probes the keychain) but every existing default + * is a synchronous `new KeyringSecretStore()` in the middle of an options + * object — `core/mcp/node/servers.ts`, `core/client/runner.ts`, + * `core/mcp/remote/node/server.ts`. Making those async would ripple + * through their callers to no benefit, since each method is already async + * and can simply await the selection it needs. So the indirection lives + * here, in one place, instead of in three signatures. + */ +class DeferredSecretStore implements SecretStore { + private async target(): Promise<SecretStore> { + return (await resolveSecretStore()).store; + } + async get(serverId: string, field: string): Promise<string | null> { + return (await this.target()).get(serverId, field); + } + async set(serverId: string, field: string, value: string): Promise<void> { + await (await this.target()).set(serverId, field, value); + } + async delete(serverId: string, field: string): Promise<void> { + await (await this.target()).delete(serverId, field); + } + async deleteAllForServer(serverId: string): Promise<void> { + await (await this.target()).deleteAllForServer(serverId); + } +} + +/** + * The default store for production call sites — what `new + * KeyringSecretStore()` used to be. Every one of them now routes through + * the same selection, so the CLI, the TUI, and the web backend cannot end + * up writing to different places on the same box. + */ +export function defaultSecretStore(): SecretStore { + return new DeferredSecretStore(); +} diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 2ccefb43..7944a23b 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -152,6 +152,28 @@ export function parseAccount( const buildAccount = (serverId: string, field: string): string => `${serverId}:${field}`; +/** + * Base type for "the active secret store cannot do this right now". + * + * Every store implementation hard-fails on `set` and only on `set` (see + * the {@link SecretStore} contract), and the API routes translate that + * one condition into a 503. Before #1950 there was only one store, so the + * routes could match on `KeychainUnavailableError` directly; now that a + * file-backed store can fail for entirely different reasons (an + * unreadable file, a changed passphrase) they match on this base instead, + * and a future store gets the same treatment by extending it rather than + * by every route learning its name. + * + * The message is the user-facing text — these are surfaced verbatim in + * the 503 body — so subclasses write advice, not diagnostics. + */ +export class SecretStoreUnavailableError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "SecretStoreUnavailableError"; + } +} + /** * Thrown when the OS keychain is unavailable. Surfaced as a 503 by the * API handlers so the UI can show an actionable error rather than a @@ -168,7 +190,7 @@ const buildAccount = (serverId: string, field: string): string => * - **It loads but exposes the wrong API** — a version or packaging * mismatch (`KeyringModuleShapeError`). Also not a daemon problem. */ -export class KeychainUnavailableError extends Error { +export class KeychainUnavailableError extends SecretStoreUnavailableError { constructor(cause: unknown) { const message = cause instanceof Error ? cause.message : String(cause); super( @@ -197,11 +219,86 @@ const hintFor = (cause: unknown, message: string): string => { return `On Linux, install libsecret / gnome-keyring.`; }; +/** + * Probe whether the OS keychain is actually usable, rather than merely + * importable. + * + * This is the input to the automatic-fallback policy in + * `secret-store-selection.ts`, and it deliberately does more than + * {@link loadKeyring}: on the container that motivated #1848 the package + * imports and shape-checks perfectly well, and the failure only appears + * when `AsyncEntry::new` tries to reach a Secret Service that isn't + * there. So the probe constructs an entry and performs a real read. + * + * A **read** and not a write, on purpose. A write would be the stronger + * signal — a keychain can in principle be readable and not writable — but + * it means depositing a value in the user's login keyring at every + * startup, for a store they may never use, that we would then have to + * clean up (and would leave behind if the process died between the two + * calls). Writing into someone's keychain uninvited to answer a question + * about our own configuration is not a trade worth making; a write that + * fails after a passing probe still surfaces as the documented 503. + * + * Never throws — the whole point is to answer a question, and a probe + * that could fail its caller would just move the crash it exists to + * prevent. + */ +export async function probeKeyringAvailable(): Promise< + { available: true } | { available: false; detail: string } +> { + const keyring = await loadKeyring(); + if (!keyring.ok) { + const err = keyring.err; + return { + available: false, + detail: err instanceof Error ? err.message : String(err), + }; + } + try { + const entry = new keyring.mod.AsyncEntry(SERVICE_NAME, PROBE_ACCOUNT); + await entry.getPassword(); + return { available: true }; + } catch (err) { + return { + available: false, + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Account used by {@link probeKeyringAvailable}. Namespaced under a server + * id that cannot collide with a real one (`:` is the account separator, so + * a real id never contains one) and never written — it only ever appears + * as the argument to a lookup that is expected to miss. + */ +const PROBE_ACCOUNT = "__inspector:probe"; + /** * Storage interface for the per-server secrets we lift off - * `~/.mcp-inspector/mcp.json`. Implemented by `KeyringSecretStore` (the - * production impl) and `InMemorySecretStore` (used in tests so the suite - * doesn't require libsecret in CI). + * `~/.mcp-inspector/mcp.json`. + * + * Three implementations, selected per run by + * `secret-store-selection.ts` (#1950) and described to the user by the + * `SecretStorageInfo` it returns: + * + * - `KeyringSecretStore` — the OS keychain, and the default wherever one + * is reachable. + * - `FileSecretStore` — `~/.mcp-inspector/secrets.json`, `0600`, + * encrypted when `MCP_INSPECTOR_SECRET_KEY` is set. The fallback on a + * host with no keychain, and on a container with a mounted volume. + * - `InMemorySecretStore` — the session-scoped store. Used by the test + * suite (so CI needs no libsecret), and as the container fallback when + * nothing durable is mounted, where it is the honest choice rather than + * the degraded one: a file in the writable layer promises persistence + * the next `docker run` will not honor. + * + * All three share one availability contract, which is what lets callers + * stay ignorant of which they got: `get` is tolerant (`null` on any + * failure), `delete` no-ops, and `set` is the only operation that + * hard-fails — throwing a {@link SecretStoreUnavailableError} the API + * routes turn into a 503 — because it is the only one where a value would + * be lost. */ export interface SecretStore { get(serverId: string, field: string): Promise<string | null>; diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts new file mode 100644 index 00000000..054cf3b4 --- /dev/null +++ b/core/auth/secret-storage-info.ts @@ -0,0 +1,124 @@ +/** + * The description of *where a typed secret ends up* — the one fact this + * whole subsystem exists to make unmissable (#1950). + * + * Pure data plus its presentation helpers, deliberately kept out of + * `core/auth/node/` so the browser can import it. The Node side resolves + * the active store (`core/auth/node/secret-store-selection.ts`) and + * produces one of these; it then travels three ways, and all three read + * the same object so they cannot disagree: + * + * 1. the startup banner the launcher prints, + * 2. `GET /api/config` → the web UI's settings-modal footers, + * 3. the `SecretStore` doc comment's contract, in prose. + * + * A store's *kind* answers "where", `plaintext` answers "readable by + * anyone who can read the file", and `durable` answers "will it still be + * there next run". Those are three separate questions and a user asking + * one of them is rarely helped by the answer to another, so none is + * folded into the others. + */ + +/** Which backend is holding secrets this session. */ +export type SecretStoreKind = "keyring" | "file" | "memory"; + +/** + * Why the active store is the active store. `configured` means the user + * named it via `MCP_INSPECTOR_SECRET_STORE`; `default` means the keychain + * was reachable and we used it; `fallback` means the keychain was *not* + * reachable and we picked something that works instead — the case that + * earns a loud banner, because it is the one where the answer to "where + * did my secret go" changed without the user asking for it. + */ +export type SecretStoreReason = "configured" | "default" | "fallback"; + +export interface SecretStorageInfo { + kind: SecretStoreKind; + reason: SecretStoreReason; + /** + * Absolute path of the secrets file, for `kind: "file"` only. Shown in + * the UI and the banner: a file-backed store whose location is a + * mystery is barely better than no answer at all. + */ + path?: string; + /** + * True when a file-backed store is writing secrets in the clear (no + * `MCP_INSPECTOR_SECRET_KEY`). Always false for the other kinds — a + * keychain is opaque to us and RAM is not "at rest" in the sense this + * flag is about. + */ + plaintext?: boolean; + /** + * True when secrets outlive the process. False only for `memory`, and + * it is the *promise* being made rather than an implementation detail: + * an in-memory store is honest precisely because it never claims + * durability it cannot deliver in a container's writable layer. + */ + durable: boolean; + /** + * Why the keychain was unreachable, when `reason === "fallback"`. Free + * text from the underlying loader/binding — surfaced rather than + * summarized, because the three realistic causes (missing libsecret, no + * platform binary, a packaging mismatch) need different fixes and only + * the raw message distinguishes them. + */ + detail?: string; +} + +/** Short label naming the store — the footer's primary text. */ +export function secretStorageLabel(info: SecretStorageInfo): string { + switch (info.kind) { + case "keyring": + return "OS keychain"; + case "file": + return info.plaintext ? "File (unencrypted)" : "File (encrypted)"; + case "memory": + return "Memory (this session only)"; + } +} + +/** + * How loudly to say it. `warn` is reserved for the two states a user + * would want to know about before typing a secret — it is on disk in the + * clear, or it is about to be lost on exit — so that the ordinary + * keychain and encrypted-file cases stay quiet and the warning keeps its + * meaning. + */ +export type SecretStorageTone = "neutral" | "warn"; + +export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone { + if (info.kind === "memory") return "warn"; + if (info.plaintext) return "warn"; + return "neutral"; +} + +/** + * One sentence of consequence, or undefined when there is none worth + * saying. Consequence, not mechanism: "will be lost when the Inspector + * exits" is what a user can act on; "uses an in-memory Map" is not. + */ +export function secretStorageCaveat( + info: SecretStorageInfo, +): string | undefined { + if (info.kind === "memory") { + return "Secrets are not written anywhere and are lost when the Inspector exits."; + } + if (info.plaintext) { + return "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them."; + } + return undefined; +} + +/** + * The full sentence, for the startup banner and any single-line surface. + * Built from the same three helpers the UI uses so the terminal and the + * browser cannot drift into describing the same store differently. + */ +export function secretStorageSummary(info: SecretStorageInfo): string { + const parts = [secretStorageLabel(info)]; + if (info.path) parts.push(`at ${info.path}`); + if (info.reason === "fallback") { + parts.push("— fell back from the OS keychain"); + } + return parts.join(" "); +} diff --git a/core/client/node-persistence.ts b/core/client/node-persistence.ts index fdb7c7af..0d769d38 100644 --- a/core/client/node-persistence.ts +++ b/core/client/node-persistence.ts @@ -3,7 +3,7 @@ */ import { - KeychainUnavailableError, + SecretStoreUnavailableError, type SecretStore, } from "../auth/node/secret-store.js"; import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js"; @@ -58,7 +58,7 @@ async function migrateClientPlaintextSecret( await writeStoreFile(filePath, serializeStore(stripped)); return stripped; } catch (err) { - if (err instanceof KeychainUnavailableError) { + if (err instanceof SecretStoreUnavailableError) { return config; } throw err; diff --git a/core/client/runner.ts b/core/client/runner.ts index 5192b674..a77db074 100644 --- a/core/client/runner.ts +++ b/core/client/runner.ts @@ -3,10 +3,8 @@ * for Node runners (TUI, CLI). */ -import { - KeyringSecretStore, - type SecretStore, -} from "../auth/node/secret-store.js"; +import type { SecretStore } from "../auth/node/secret-store.js"; +import { defaultSecretStore } from "../auth/node/secret-store-selection.js"; import type { InspectorClientOptions, InspectorServerSettings, @@ -25,8 +23,9 @@ import { export interface LoadRunnerClientConfigOptions { /** Explicit path from `--client-config` (or MCP_CLIENT_CONFIG_PATH when unset). */ clientConfigPath?: string; - /** Secret store for the IdP clientSecret; defaults to the OS keychain. Tests - * inject an in-memory store for determinism. */ + /** Secret store for the IdP clientSecret; defaults to the store selected for + * this host (OS keychain, or its file/memory fallback). Tests inject an + * in-memory store for determinism. */ secretStore?: SecretStore; } @@ -38,7 +37,7 @@ export async function loadRunnerClientConfig( options?.clientConfigPath?.trim() || process.env.MCP_CLIENT_CONFIG_PATH?.trim() || undefined; - const secretStore = options?.secretStore ?? new KeyringSecretStore(); + const secretStore = options?.secretStore ?? defaultSecretStore(); return loadClientConfig({ filePath: customPath, secretStore }); } diff --git a/core/mcp/node/servers.ts b/core/mcp/node/servers.ts index 532f2976..9e10c69a 100644 --- a/core/mcp/node/servers.ts +++ b/core/mcp/node/servers.ts @@ -1,7 +1,5 @@ -import { - KeyringSecretStore, - type SecretStore, -} from "../../auth/node/secret-store.js"; +import type { SecretStore } from "../../auth/node/secret-store.js"; +import { defaultSecretStore } from "../../auth/node/secret-store-selection.js"; import type { InspectorServerSettings, MCPServerConfig } from "../types.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js"; import { mcpConfigToServerEntries } from "../serverList.js"; @@ -32,7 +30,8 @@ export type ResolvedServer = { * broadcast into every resolved server's settings. */ export type ServerLoadOptions = ServerConfigOptions & { headers?: Record<string, string>; - /** Test injection; defaults to {@link KeyringSecretStore} for catalog/config loads. */ + /** Test injection; defaults to the selected store (see + * `secret-store-selection.ts`) for catalog/config loads. */ secretStore?: SecretStore; }; @@ -104,7 +103,7 @@ export async function loadServerEntries( if (source) { let config = readServerListFile(source.path, source.writable); - const secretStore = serverOptions.secretStore ?? new KeyringSecretStore(); + const secretStore = serverOptions.secretStore ?? defaultSecretStore(); config = await rehydrateMcpConfigFromKeychain(config, secretStore); const entries = mcpConfigToServerEntries(config); const result: Record<string, ResolvedServer> = {}; diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 5bc58f5e..4341954f 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -65,10 +65,10 @@ import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; import { - KeychainUnavailableError, - KeyringSecretStore, + SecretStoreUnavailableError, type SecretStore, } from "../../../auth/node/secret-store.js"; +import { defaultSecretStore } from "../../../auth/node/secret-store-selection.js"; import { deleteClientConfigStore, readClientConfigStore, @@ -76,6 +76,7 @@ import { } from "../../../client/node-persistence.js"; import { formatClientConfigLoadError } from "../../../client/config-parse.js"; import { envSecretField } from "../../../auth/secret-fields.js"; +import type { SecretStorageInfo } from "../../../auth/secret-storage-info.js"; import { ZodError } from "zod"; /** @@ -189,6 +190,15 @@ export interface InitialConfigPayload { * a legacy backend that predates the field; the UI just shows nothing then. */ version?: string; + /** + * Where secrets typed into this session end up (#1950): the OS keychain, + * a file, or memory. The browser cannot work this out for itself — the + * store lives entirely on the Node side — and it is what the permanent + * footer in the Client/Server Settings modals reports. Absent on a + * legacy backend, in which case the footer renders nothing rather than + * guessing "keychain", since a wrong answer here is worse than none. + */ + secretStorage?: SecretStorageInfo; } export interface RemoteServerOptions { @@ -243,9 +253,11 @@ export interface RemoteServerOptions { /** * Backend for per-server secret values that we keep out of mcp.json * (OAuth client secret, stdio env values). Defaults to a - * `KeyringSecretStore` that talks to the OS keychain via - * `@napi-rs/keyring`. Tests inject `InMemorySecretStore` so the suite - * doesn't need libsecret on Linux CI runners. + * store selected for this host — the OS keychain where one is + * reachable, otherwise the file or in-memory fallback chosen by + * `secret-store-selection.ts` (#1950). Tests inject + * `InMemorySecretStore` so the suite doesn't need libsecret on Linux CI + * runners. */ secretStore?: SecretStore; } @@ -490,8 +502,7 @@ export function createRemoteApp( const { logger: fileLogger, allowedOrigins } = options; const storageDir = options.storageDir ?? getDefaultStorageDir(); const mcpConfigPath = options.mcpConfigPath ?? getDefaultMcpConfigPath(); - const secretStore: SecretStore = - options.secretStore ?? new KeyringSecretStore(); + const secretStore: SecretStore = options.secretStore ?? defaultSecretStore(); // Read-only session support (#1481/#1483). `writable: false` rejects every // /api/servers mutation and suppresses the seed/migrate writes on read. @@ -1830,9 +1841,11 @@ export function createRemoteApp( // Centralizes the "write the secrets in this entry to the keychain" and // "fetch all secrets for this entry from the keychain" steps so the // POST/PUT/DELETE/GET handlers stay readable. Each operation translates a - // `KeychainUnavailableError` from the underlying store into a Hono 503 - // — the only realistic trigger is Linux without libsecret, and the user - // can install it without restarting. + // `SecretStoreUnavailableError` from the underlying store into a Hono + // 503 — a keychain the user can install without restarting (Linux + // without libsecret), or a secrets file we refuse to overwrite because + // the passphrase changed (#1950). Both are fixable outside the process, + // which is what makes 503 the right code rather than 500. // Same Promise.all reasoning as `readKeychainEntriesFor`: each set // is a native round-trip and there's no ordering requirement among @@ -1896,7 +1909,7 @@ export function createRemoteApp( // knows whether to persist the cleanup. // // When the keychain is unavailable (Linux without libsecret), the - // first `secretStore.set` throws `KeychainUnavailableError`. We catch + // first `secretStore.set` throws `SecretStoreUnavailableError`. We catch // it and abandon the migration for this GET — the on-disk file stays // as-is so the user's secret isn't lost, and the next GET retries // (e.g. after the user installs libsecret). @@ -1926,7 +1939,7 @@ export function createRemoteApp( changed = true; } } catch (err) { - if (err instanceof KeychainUnavailableError) { + if (err instanceof SecretStoreUnavailableError) { if (fileLogger) { fileLogger.warn( { err: err.message }, @@ -2016,7 +2029,7 @@ export function createRemoteApp( c: Context, err: unknown, ): Response | undefined => { - if (err instanceof KeychainUnavailableError) { + if (err instanceof SecretStoreUnavailableError) { return c.json({ error: err.message }, 503); } return undefined; @@ -2182,7 +2195,7 @@ export function createRemoteApp( // goes to disk, the values go to the keychain. const { stripped, secrets } = extractSecretsFromStored(built); // Order: sweep → keychain → disk. The keychain write is the - // only step that can hard-fail (KeychainUnavailableError on + // only step that can hard-fail (SecretStoreUnavailableError on // `set`); doing it before the disk write means a 503 leaves no // disk entry behind, so a retry POST isn't trapped at 409. The // initial sweep handles the case where a previous DELETE failed @@ -2439,7 +2452,7 @@ export function createRemoteApp( // disk file, then clean up obsolete keychain entries. // // - Keychain set is the only hard-fail step (it raises 503 on - // `KeychainUnavailableError`). Doing it first means a failed + // `SecretStoreUnavailableError`). Doing it first means a failed // set leaves both disk and keychain in their pre-PUT state; // the user retries and nothing is half-applied. // - The disk write happens after the keychain is fully primed, diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index 884b2370..a17ecbc8 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -18,6 +18,11 @@ * `package.json` (the browser can't read it off disk). `undefined` until the * fetch resolves, and whenever the backend omits it (legacy backend) — the UI * renders nothing then. + * - `secretStorage` — which store this session's secrets go to (#1950), + * resolved on the Node side where the keychain probe happens. `undefined` + * until the fetch resolves and on a backend that omits it; the settings-modal + * footers render nothing then, because a guessed answer under a secret field + * is worse than no answer. * * This consolidates the three former single-field hooks (`useSandboxUrl`, * `useServerListWritable`, `useInspectorVersion`), each of which fetched the @@ -29,6 +34,7 @@ */ import { useCallback, useEffect, useState } from "react"; +import type { SecretStorageInfo } from "../auth/secret-storage-info.js"; export interface UseInitialConfigOptions { /** Base URL of the remote server (typically `window.location.origin`). */ @@ -46,6 +52,8 @@ export interface UseInitialConfigResult { sandboxUrl: string | undefined; /** Whether the server list is writable (catalog) or read-only (session). */ writable: boolean; + /** Where this session's secrets are stored, or undefined when unknown. */ + secretStorage: SecretStorageInfo | undefined; /** True while the initial fetch is in flight. */ loading: boolean; } @@ -55,6 +63,24 @@ interface ConfigPayload { version?: unknown; sandboxUrl?: unknown; writable?: unknown; + secretStorage?: unknown; +} + +/** + * Narrow the payload's `secretStorage` before trusting it. + * + * The footer states, in the UI, where a user's secret is about to go, so a + * malformed or partial descriptor must render as "unknown" rather than as a + * confident half-answer — the `kind` alone is what every label and tone + * derives from, so it is the field the check turns on. + */ +function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { + if (typeof value !== "object" || value === null) return undefined; + const kind = (value as { kind?: unknown }).kind; + if (kind !== "keyring" && kind !== "file" && kind !== "memory") { + return undefined; + } + return value as SecretStorageInfo; } /** Coerce a payload field to a usable non-empty string, else undefined. */ @@ -74,6 +100,9 @@ export function useInitialConfig( // Default writable so the common (catalog) case shows CRUD immediately and a // legacy backend that omits the field keeps working. const [writable, setWritable] = useState<boolean>(true); + const [secretStorage, setSecretStorage] = useState< + SecretStorageInfo | undefined + >(undefined); const [loading, setLoading] = useState<boolean>(true); // `isCancelled` lets the effect drop a response that resolves after unmount or @@ -100,6 +129,7 @@ export function useInitialConfig( // Only an explicit `false` makes the list read-only; a missing field // (legacy backend) stays writable. setWritable(body.writable !== false); + setSecretStorage(usableSecretStorage(body.secretStorage)); } catch { // Network error / aborted fetch: leave every field at its default // (version/sandboxUrl undefined, writable true). @@ -118,5 +148,5 @@ export function useInitialConfig( }; }, [load]); - return { version, sandboxUrl, writable, loading }; + return { version, sandboxUrl, writable, secretStorage, loading }; } From 3452a6310144529a4112938eb555ece79b75bd8e Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 12:26:33 -0400 Subject: [PATCH 231/941] fix(auth): address Copilot review on the secret-store PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, all taken. - The descriptor reported the *write policy*, not the file. Adding `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext file flipped `store.encrypted` immediately, so the banner and footer claimed "File (encrypted)" while the existing secrets sat readable until the next `set`. `FileSecretStore.readOnDiskEncryption()` now reads the envelope (never the payload, so it needs no passphrase) and the descriptor is built from that. The transitional state gets its own `pendingEncryption` flag, which changes the advice without softening the verdict — "set a passphrase" is the wrong thing to tell someone who already has, so the caveat names the pending write instead. - The secrets file ignored the configured storage directory. A container mounting only that directory was judged unmounted, fell back to `memory`, and lost secrets the user had arranged to keep. `defaultSecretFilePath()` now follows `MCP_STORAGE_DIR` — the variable that already relocates OAuth tokens and `client.json` — between the explicit `MCP_INSPECTOR_SECRET_FILE` and the `~/.mcp-inspector` default. (The issue comment called it `MCP_INSPECTOR_STORAGE_DIR`; no such variable exists.) - `version` was never checked before the plaintext branch, so a `{ version: 2, encryption: "none" }` file was read and silently rewritten as version 1, discarding whatever the newer format added — the same destroy-to-satisfy-an-additive-request case the encrypted branch already refuses. Checked first now, so the answer no longer depends on which encryption mode the newer writer chose. - The doc comment claimed a per-file salt made "a short, memorable value safe to use". It does not: the salt defeats precomputation, not guessing, and the scrypt cost is deliberately a few milliseconds because it runs on every read and write. Both the comment and the README now say to use a high-entropy value, generated rather than chosen. - `usableSecretStorage` validated only `kind`, so a partial `{ kind: "file" }` was cast through and — because a missing `plaintext` is falsy — rendered as the quiet, neutral *encrypted* file. That is the confident half-answer the guard exists to reject, produced by the absence of information rather than by any claim the backend made. `reason`, `durable`, and each kind's own required fields are checked. - The footer's inline `Tooltip` carried four static props; extracted to a `CaveatTooltip` constant per the `.withProps()` rule. Tests cover each: the policy/file disagreement in both directions and the no-file-yet fallback, `MCP_STORAGE_DIR` and its precedence against `MCP_INSPECTOR_SECRET_FILE`, the version refusal (asserting the bytes survive it) split from the unrecognized-cipher refusal it used to be conflated with, `readOnDiskEncryption` including the keyless and unparseable cases, five new rejected descriptor shapes, and the changed caveat. Full `npm run ci` green. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 96 ++++++++++--------- .../SecretStorageFooter.tsx | 17 ++-- .../core/auth/secret-storage-info.test.ts | 16 ++++ .../test/core/react/useInitialConfig.test.tsx | 21 ++++ .../auth/node/file-secret-store.test.ts | 91 +++++++++++++++++- .../auth/node/secret-store-selection.test.ts | 83 ++++++++++++++++ core/auth/node/file-secret-store.ts | 68 ++++++++++++- core/auth/node/secret-store-selection.ts | 41 ++++++-- core/auth/secret-storage-info.ts | 26 ++++- core/react/useInitialConfig.ts | 27 +++++- 10 files changed, 411 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 97e75e05..eb0624d7 100644 --- a/README.md +++ b/README.md @@ -139,22 +139,22 @@ This is what lets an Inspector connection negotiating `protocolEra: "auto" | "mo Each config below is a ready-made server for exercising one feature by hand. Load one with `--config`, and unless noted, connect with **Protocol Era = Modern**. -| Config | Demonstrates | Issue | -| ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | +| Config | Demonstrates | Issue | +| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | -| `modern-mrtr-http.json` | A single MRTR round-trip | — | -| `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) | -| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | -| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | -| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | -| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | -| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | -| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | +| `modern-mrtr-http.json` | A single MRTR round-trip | — | +| `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) | +| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | +| `xmcpheader-modern-http.json` | Tools tab: `x-mcp-header` mirroring and exclusions | [#1632](https://github.com/modelcontextprotocol/inspector/issues/1632) | +| `pagination-http.json` | Page-by-page list fetching | [#1721](https://github.com/modelcontextprotocol/inspector/issues/1721) | +| `structured-output-http.json` | Tools tab: a result's `structuredContent` section | [#1908](https://github.com/modelcontextprotocol/inspector/issues/1908) | +| `duplicate-tool-names-http.json` | A `tools/list` that repeats a tool name | [#1957](https://github.com/modelcontextprotocol/inspector/issues/1957) | +| `nullable-fields-http.json` | Tools tab: nullable (`anyOf` + `null`) arguments | [#1928](https://github.com/modelcontextprotocol/inspector/issues/1928) | | `rfc6570-templates-http.json` | Resources tab: RFC 6570 resource-template expansion | [#1919](https://github.com/modelcontextprotocol/inspector/issues/1919) | -| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | -| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | -| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | -| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | +| `advertised-extensions-http.json` | Tool registration gated on advertised extensions | [#1739](https://github.com/modelcontextprotocol/inspector/issues/1739) | +| `logging-{legacy,modern}-http.json` | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) | +| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) | +| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) | #### MCP Apps @@ -266,23 +266,23 @@ Open the Resources tab and pick **events_by_topic**, then enter `foo/bar`. The r The web client and the TUI expand through one shared helper, [`core/mcp/uriTemplate.ts`](./core/mcp/uriTemplate.ts) — the web Resources form directly, the TUI via `InspectorClient.readResourceFromTemplate` — and both derive their **form fields** from its parser too, which is the half that makes the sharing real: a form submits values under the names it rendered, so a parser that mangles a name silently drops the value at expansion time. (The CLI is not a consumer: it has no template form, and its `resources/read` passes the already-expanded `--uri` straight through.) -The SDK's `UriTemplate` is still used, but only to *validate* a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: +The SDK's `UriTemplate` is still used, but only to _validate_ a template (constructing it is what rejects an unclosed expression). Its expander is not, because it is incomplete in five ways — each measured against the pinned SDK, not inferred: -| Shape | SDK behavior | -| --- | --- | -| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | -| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` | -| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | +| Shape | SDK behavior | +| --------------- | -------------------------------------------------------------------------------------------------------------- | +| `{a,b}` | raw-joins the values — no encoding, operator prefix dropped | +| `{;id}` | `;` is missing from its operator list, so the variable parses as `;id` | +| `{id:3}` | the prefix modifier is folded into the name, giving `id:3` | | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` (`[::1]` → `%5B::1%5D`) and double-encodes pct-triplets (`%2F` → `%252F`) | -| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded | +| `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare, which RFC 6570 requires encoded | The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the form renders fields literally labelled `;id` and `id:3`. The `+`/`#` row is silent corruption rather than over-escaping — an IPv6 literal or an already-encoded path arrives at the server altered. A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. -Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. +Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the _names_ a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. -An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. +An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a _form_ concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). @@ -341,18 +341,18 @@ Individual clients: `build:web`, `build:cli`, `build:tui`, `build:launcher`. The Each client self-validates from its own folder; the root scripts chain them. There is **no** aggregate root `test` script — use `validate` (fast) or `coverage` (the gate). -| Script | What it does | -| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | -| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | -| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | -| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | -| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | -| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | -| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only *within a major version*. Runs in `validate`. -| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | -| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | +| Script | What it does | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | +| `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | +| `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | +| `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards _this_ gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | +| `npm run verify:typecheck-coverage` | The typecheck-coverage analog of the above (#1791): for each Node client (auto-discovered from disk — enrolled via its `typecheck` script's projects, or for a `tsc -b` client like `clients/web` via its `tsconfig.json` `references`) it runs those projects with `tsc --listFilesOnly`, unions them, and **fails** listing any tracked `.ts`/`.tsx`/`.mts`/`.cts` under the client that lands in no project (so a new top-level config/helper can't silently go untypechecked). It also requires, deny-by-default, the first-party TS no client owns (`test-servers/src`, the root `vitest.shared.mts`, all of `core/`, and any new top-level location) to land in some client project's tsc pass — so a `core` `*.tsx` web's projects don't reach is caught too. Also asserts the gate is wired (each client's typecheck pass — its `typecheck` script, or web's `tsc -b` — is reachable from its `validate`, and the root chain runs each client's `validate`). Runs in `validate`. | +| `npm run verify:dep-lockstep` | Guards the "one version per install-crossing dependency" invariant (#1896). v2 is not a workspace, so a client's test project compiles the shared first-party TypeScript — `core/`, `test-servers/src`, and the root-owned `vitest.shared.mts`, all of which resolve their dependencies from the **root** install — alongside the client's own sources, putting the same package in one `tsc` program twice. At the same version that's harmless; skewed, TypeScript must relate two structurally-distinct copies of every type, which for a recursive-generic surface is exponential (zod `4.3.6` vs `4.4.3` exhausted the 4GB tsc heap in `clients/web`). Derives its candidate set from **what actually enters each program** (#1965) — every client tsconfig project listed with `tsc --listFilesOnly` via the shared `scripts/lib/tsc-program.mjs`, each resolved `node_modules` file mapped to its owning install, keeping the packages that reach one program from two installs (a package whose declarations arrive only through another package's `.d.ts`, as `@modelcontextprotocol/sdk`'s do, is invisible to a scan of first-party imports). Prices each copy from the lockfile entry for the exact install path the program resolved, compares only the installs that met in one program, and **fails deny-by-default** on any disagreement not in the annotated `TOLERATED_SKEW` allowlist — empty today — with an allowlisted package tolerated only _within a major version_. Runs in `validate`. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run pack:verify` | Publish smoke — see [Publishing](#publishing). | Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), plus root `validate:core` / `format:core` for the shared `core/` package, `format:scripts` for the root `scripts/` tooling, and `format:shared` / `lint:shared` for the root "shared" surface (`test-servers/src/**`, `vitest.shared.mts`, the root `eslint.config.js`). Run `npm run format` before committing — the root `format` fixes `core/`, the root `scripts/`, the shared surface, and every client; `validate` runs the non-fixing `format:check` and fails CI on any unformatted file. @@ -371,7 +371,7 @@ The root `package.json` `"files"` allowlist is the source of truth for the tarba - **No source maps.** The client bundlers set `sourcemap: false` (`clients/{cli,tui}/tsup.config.ts`, `clients/web/tsup.runner.config.ts`); Vite and the launcher's `tsc` already emit none. Maps are ~half the unpacked size and aren't needed at runtime — debug via `npm run dev` on the source. - **`clients/web/build` ships via `clients/web/.npmignore`.** `clients/web/.gitignore` lists `build/`, and npm's packlist honors that nested `.gitignore` over the root `"files"` allowlist — so the prod web-server runner was silently missing from the tarball while `clients/web/dist` slipped through (its `.gitignore` only lists `dist-ssr`). `clients/web/.npmignore` overrides the `.gitignore` for publishing so both `build/` (runner) and `dist/` (SPA) ship. The other clients don't need this — none ship a nested `.gitignore`. - **`clients/web/static` ships the MCP Apps sandbox proxy.** `clients/web/static/sandbox_proxy.html` is a committed source file (not a build artifact), read from disk at runtime by `clients/web/server/sandbox-controller.ts` as `<runner dir>/../static/sandbox_proxy.html`. It was missing from the root `"files"` allowlist entirely, so every published build failed the Apps tab with **"Sandbox not loaded"** ([#1859](https://github.com/modelcontextprotocol/inspector/issues/1859)) while working fine in the repo. Because the path is resolved _relative to_ `clients/web/build`, the directory must ship at that exact location — `pack:verify` asserts both the tarball entry and the installed-on-disk path. -- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying *its* peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — *not* because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). +- **A dependency that renders React is bundled, not externalized.** An externalized package resolves its own `react` from wherever npm placed **it** in the consumer's tree, which is not necessarily where the bundle resolves ours — npm places a package beside a React satisfying _its_ peer range, and those ranges are looser than ours. `ink-form` and `ink-scroll-view` declare `">=18"`, so a project holding React 18 satisfies them and gets them hoisted while the Inspector's React 19 nests underneath: two React copies, and the TUI dies with `TypeError: Cannot read properties of null (reading 'useState')` the moment a tool test form or a scroll view mounts ([#1952](https://github.com/modelcontextprotocol/inspector/issues/1952)). Both are therefore inlined by `clients/tui/tsup.config.ts` and are **not** root dependencies: the tarball ships their code inside `clients/tui/build/index.js` rather than having consumers install them. Bundling also pins their transitive deps to what this repo's install resolved (notably `ink-select-input@6` via `overrides`, which npm ignores for a package installed as a dependency). **`ink` is the one exception, on cost:** bundling it works but adds ~1.4 MB (`react-reconciler` and `yoga-layout` come along, plus a `createRequire` banner for the inlined CJS), so it stays external — _not_ because its `">=19"` peer makes it safe, which it does not. What keeps that tolerable is the root `react` range: `"^19.0.0"` is deliberately open to the whole major so npm can dedupe our React with whatever React 19 a consumer pins, leaving an external `ink` on the same copy the bundle uses. **Narrowing that range reopens the bug for the renderer itself** — `clients/tui/__tests__/tsupConfig.test.ts` pins it to `ink`'s peer floor, and guards the rest of the split; see the [TUI README](./clients/tui/README.md#bundling-react-rendering-dependencies-must-be-inlined-1952). - **A single version number, read from the root `package.json`.** The Inspector ships as one package with one version, so only the **root** `package.json` carries a `version` — the four `clients/*/package.json`s deliberately have none. Every Node client (CLI, TUI, and the web backend) resolves the version through the shared `readInspectorVersion()` reader in `core/node/version.ts`, which walks up to the root manifest (always present in the tarball). No client `package.json` is read at runtime, so none needs to ship. The web **browser** can't read the filesystem; it gets its version from the backend via `GET /api/config` (see [#1639](https://github.com/modelcontextprotocol/inspector/issues/1639)). ### `npm run pack:verify` — publish smoke against the real tarball @@ -403,7 +403,7 @@ npm version minor --no-git-tag-version # or major / patch; bump only, no tag **2. Merge `v2/main` → `main`** through the usual milestone-merge branch. It now carries the bump, so the release lands on `main` with the version already correct. -Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is *post-release* drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. +Between steps 1 and 2 the two branches **do** differ, and that is expected, not drift: `v2/main` reads the version being built while `main` still reads the one currently released. What this ordering removes is _post-release_ drift — once the milestone merge lands they agree again, and `v2/main` is never left **behind** `main`. If you see `v2/main` ahead of `main`, a release is in flight; if you see it behind, something went wrong. **3. Tag the `main` commit and draft the Release:** @@ -419,7 +419,7 @@ git tag 2.3.0 origin/main && git push origin 2.3.0 The release's target commit selects which workflow runs, so this only publishes when a release is cut from a commit carrying this (v2) workflow. -**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only *downstream* of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. +**Why the bump goes on `v2/main` first ([#2010](https://github.com/modelcontextprotocol/inspector/issues/2010)).** It used to happen on the milestone-merge branch, which is cut from `main` — so the bump existed only _downstream_ of `v2/main` and nothing carried it back. `v2/main` sat at `2.0.0` through both the 2.1.0 and 2.2.0 releases. That is not cosmetic: a branch cut from a milestone-merge branch silently carries the bump into an unrelated PR (this happened on [#2009](https://github.com/modelcontextprotocol/inspector/issues/2009), where a container bugfix arrived with a `2.0.0 → 2.2.0` diff), and anything reading the version in development — `readInspectorVersion()`, `--version`, `GET /api/config` — reported a version two releases old. Do **not** "fix" a future drift by merging `main` back into `v2/main`. `main` carries the entire pre-v2 v1 history (retained through `ec5d8e13 chore: replace main's tree with v2` — ~230 commits `v2/main` does not have), so a back-merge grafts all of it into the develop branch's log permanently in order to deliver a two-file change. Bumping first means there is nothing to back-merge. @@ -457,17 +457,17 @@ docker run --rm -p 127.0.0.1:6274:6274 \ The same volume also persists OAuth tokens and stored state, so an authorized server stays authorized across runs. Use `-e MCP_CATALOG_PATH=/some/other/path.json` to put the catalog somewhere else — mount a volume covering whatever directory you point it at. If you **bind-mount a host directory** instead of a named volume (`-v "$PWD/inspector-data:/home/node/.mcp-inspector"`), the directory keeps its host ownership, so on Linux add `--user "$(id -u):$(id -g)"` or `chown` it to uid `1000` — otherwise the non-root `node` user can't write and adding a server fails with `EACCES`. -**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does *not* write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive: +**Where secrets go, and how to make them survive (#1950).** The Inspector keeps the values it deliberately does _not_ write to `mcp.json` — an OAuth client secret, an enterprise IdP client secret, each stdio `env:` value — in the **OS keychain**. A container has no keychain (the published image has no D-Bus session), so on startup the Inspector probes for one and falls back, saying so in the logs and in a permanent footer at the bottom of the Client Settings and Server Settings dialogs. Which fallback you get depends on whether the directory it would write to is going to survive: -| Situation | Store | Secrets survive a restart? | -| --- | --- | --- | -| Keychain reachable (a normal desktop install) | OS keychain | Yes | -| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only | -| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | +| Situation | Store | Secrets survive a restart? | +| -------------------------------------------------------------- | -------------------------------------------- | -------------------------- | +| Keychain reachable (a normal desktop install) | OS keychain | Yes | +| Container, **no volume** on `/home/node/.mcp-inspector` | Memory | No — session only | +| Container **with** that volume, or any host without a keychain | `~/.mcp-inspector/secrets.json`, mode `0600` | Yes | So the same volume that keeps your server list also switches secrets from session-scoped to durable — nothing extra to configure. The in-memory default for an unmounted container is deliberate: a file in the writable layer is discarded by `--rm` and by every image update, and promising durability it can't deliver is worse than declining to. -**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` to any passphrase and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone: +**A file-backed store is unencrypted unless you give it a key.** Set `MCP_INSPECTOR_SECRET_KEY` and the file is encrypted with AES-256-GCM (the passphrase is stretched with scrypt against a per-file random salt). Without it the file is still `0600`, but the values are readable to anyone who can read the file — which the startup log and the settings footer both say, every session, in a warning tone: ```bash docker run --rm -p 127.0.0.1:6274:6274 \ @@ -476,9 +476,11 @@ docker run --rm -p 127.0.0.1:6274:6274 \ ghcr.io/modelcontextprotocol/inspector ``` -Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. **Changing or losing it is not**: a file that can no longer be decrypted is read as empty and *refuses to be written*, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. +**Use a high-entropy passphrase — generated, not chosen.** The random salt stops an attacker precomputing a table across files; it does nothing against _guessing_, and the scrypt cost is deliberately low because the derivation runs on every read and write. Anyone who obtains `secrets.json` can therefore test candidate passphrases quickly and offline, so treat this value like any other credential rather than like a memorable password. -Two env vars override the automatic choice: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, and `MCP_INSPECTOR_SECRET_FILE` moves the file. +Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. Until that write happens the existing values really are still readable, and the banner and footer keep saying so rather than reporting the file as encrypted the moment the variable appears. **Changing or losing the passphrase is not safe**: a file that can no longer be decrypted is read as empty and _refuses to be written_, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. + +Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 252e6a66..008787f8 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -66,6 +66,15 @@ const FooterDetail = Text.withProps({ flex: 1, }); +// `multiline` + a fixed width so the sentence wraps instead of forming a +// tooltip wider than the modal it explains. Only the `label` is dynamic. +const CaveatTooltip = Tooltip.withProps({ + multiline: true, + w: 320, + position: "top", + withArrow: true, +}); + const ICON_SIZE = 14; /** @@ -129,11 +138,5 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) { const tip = [caveat, info.path].filter(Boolean).join(" "); if (!tip) return band; - return ( - // `multiline` + a width so the sentence wraps instead of forming a tooltip - // wider than the modal it explains. - <Tooltip label={tip} multiline w={320} position="top" withArrow> - {band} - </Tooltip> - ); + return <CaveatTooltip label={tip}>{band}</CaveatTooltip>; } diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 8c55c157..1d60a92d 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -87,6 +87,22 @@ describe("secretStorageCaveat", () => { expect(caveat).toContain("MCP_INSPECTOR_SECRET_KEY"); }); + it("changes the advice once a passphrase is set but not yet applied", () => { + // Telling someone to set MCP_INSPECTOR_SECRET_KEY when they already have + // is advice that cannot clear the condition. The condition here is the + // pending write, so that is what the sentence names — while the verdict + // (still unencrypted, still a warning) stays exactly the same. + const pending: SecretStorageInfo = { + ...plaintextFile, + pendingEncryption: true, + }; + expect(secretStorageTone(pending)).toBe("warn"); + expect(secretStorageLabel(pending)).toBe("File (unencrypted)"); + const caveat = secretStorageCaveat(pending); + expect(caveat).toContain("next time a secret is saved"); + expect(caveat).not.toContain("MCP_INSPECTOR_SECRET_KEY"); + }); + it("prefers the memory caveat when a memory store somehow carries the flag", () => { // Defensive ordering: `plaintext` is meaningless for memory, and the // durability loss is the more consequential of the two statements. diff --git a/clients/web/src/test/core/react/useInitialConfig.test.tsx b/clients/web/src/test/core/react/useInitialConfig.test.tsx index ecf3aad4..ac70b6a1 100644 --- a/clients/web/src/test/core/react/useInitialConfig.test.tsx +++ b/clients/web/src/test/core/react/useInitialConfig.test.tsx @@ -289,6 +289,27 @@ describe("useInitialConfig", () => { ["null", null], ["an unknown kind", { kind: "vault", durable: true }], ["a missing kind", { durable: true, path: "/x" }], + // The dangerous one: `kind` alone passed the old check, and a missing + // `plaintext` is falsy, so it rendered as the quiet *encrypted* file — + // the most misleading thing this footer can say, produced by the absence + // of information rather than by anything the backend claimed. + ["a file descriptor with nothing but a kind", { kind: "file" }], + [ + "a file descriptor missing its path", + { kind: "file", reason: "fallback", durable: true, plaintext: false }, + ], + [ + "a file descriptor missing plaintext", + { kind: "file", reason: "fallback", durable: true, path: "/x" }, + ], + [ + "a descriptor with an unknown reason", + { kind: "keyring", reason: "vibes", durable: true }, + ], + [ + "a descriptor with a non-boolean durable", + { kind: "memory", reason: "fallback", durable: "no" }, + ], ])("rejects %s", async (_label, value) => { const fetchFn = vi .fn() diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 96152517..369546fa 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -225,6 +225,31 @@ describe("FileSecretStore failure handling", () => { ); }); + it("refuses a file written by a newer format version", async () => { + // Readable as far as the plaintext branch is concerned, which is the trap: + // accepting it means the next write rewrites it as version 1 and silently + // drops whatever the newer format added. Same refusal the encrypted branch + // already gives, so the answer doesn't depend on which mode the newer + // writer happened to choose. + await fs.writeFile( + filePath(), + JSON.stringify({ + version: 2, + encryption: "none", + secrets: { "a:b": "1" }, + }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + expect(await store.get("a", "b")).toBe(null); + await expect(store.set("a", "b", "2")).rejects.toThrow(/version 2/); + // The bytes are still there — nothing was destroyed to answer the write. + const raw = JSON.parse(await fs.readFile(filePath(), "utf-8")) as { + version: number; + }; + expect(raw.version).toBe(2); + }); + it("delete stays silent on a file it cannot decrypt", async () => { await writeEncryptedFixture(); const store = new FileSecretStore({ @@ -246,15 +271,34 @@ describe("FileSecretStore failure handling", () => { expect(await store.get("alpha", "env:A")).toBe(null); }); - it("set reports a file written in a format this build doesn't know", async () => { + it("set reports a file written by a newer format version", async () => { // The forward-compat case: a newer Inspector's file, opened by an older // one. Refusing beats truncating it to the entries we happen to parse. + // The version is checked before the encryption mode, so this is the + // message even though the cipher is also unrecognized — the version is + // the more actionable of the two ("upgrade"), and it is the reason the + // file is unreadable regardless of what the cipher turned out to be. await fs.writeFile( filePath(), JSON.stringify({ version: 99, encryption: "chacha20-poly1305" }), "utf-8", ); const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( + /format version 99/, + ); + }); + + it("set reports an unrecognized cipher at a version it does understand", async () => { + // The other half: a file this build's version check accepts, whose + // encryption mode it has no code for. A different failure with different + // advice, so it must not collapse into the version message. + await fs.writeFile( + filePath(), + JSON.stringify({ version: 1, encryption: "chacha20-poly1305" }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); await expect(store.set("alpha", "env:A", "1")).rejects.toThrow( /unsupported format/, ); @@ -409,3 +453,48 @@ describe("tightenSecretFilePermissions", () => { ).resolves.toBeUndefined(); }); }); + +describe("readOnDiskEncryption", () => { + it("reports what the file is, not what the store would write", async () => { + // The whole reason it exists: these two disagree for a full session after + // a passphrase is added to an install that already has a plaintext file. + const plain = new FileSecretStore({ filePath: filePath() }); + await plain.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + + const withKey = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(withKey.encrypted).toBe(true); + expect(await withKey.readOnDiskEncryption()).toBe("none"); + + await withKey.set("alpha", "env:A", "1"); + expect(await withKey.readOnDiskEncryption()).toBe("aes-256-gcm"); + }); + + it("answers null for an absent, unparseable, or unrecognized file", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + expect(await store.readOnDiskEncryption()).toBe(null); + + await fs.writeFile(filePath(), "{not json", "utf-8"); + expect(await store.readOnDiskEncryption()).toBe(null); + + await fs.writeFile( + filePath(), + JSON.stringify({ version: 1, encryption: "rot13" }), + "utf-8", + ); + expect(await store.readOnDiskEncryption()).toBe(null); + }); + + it("needs no passphrase — it reads the envelope, never the payload", async () => { + // A store whose key is wrong still has to be describable in the UI. + const writer = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", + }); + await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + const keyless = new FileSecretStore({ filePath: filePath() }); + expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm"); + }); +}); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 90b96bfe..b77dec18 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -33,6 +33,7 @@ const ENV_KEYS = [ "MCP_INSPECTOR_SECRET_STORE", "MCP_INSPECTOR_SECRET_FILE", "MCP_INSPECTOR_SECRET_KEY", + "MCP_STORAGE_DIR", "KUBERNETES_SERVICE_HOST", ]; @@ -381,4 +382,86 @@ describe("defaultSecretFilePath", () => { path.join(".mcp-inspector", "secrets.json"), ); }); + + it("follows MCP_STORAGE_DIR when no explicit file is named", async () => { + // The variable that already relocates OAuth tokens and client.json. A + // container mounting only that directory must not be judged unmounted and + // demoted to `memory` — the user arranged for exactly this directory to + // survive. + process.env.MCP_STORAGE_DIR = tmpDir; + const mod = await loadWithProbe(true); + expect(mod.defaultSecretFilePath()).toBe(path.join(tmpDir, "secrets.json")); + }); + + it("lets an explicit MCP_INSPECTOR_SECRET_FILE outrank MCP_STORAGE_DIR", async () => { + // Naming the file outright is the more specific statement of the two. + process.env.MCP_STORAGE_DIR = tmpDir; + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "elsewhere.json"); + const mod = await loadWithProbe(true); + expect(mod.defaultSecretFilePath()).toBe( + path.join(tmpDir, "elsewhere.json"), + ); + }); +}); + +describe("the descriptor reports the file, not the write policy", () => { + it("says plaintext while a pre-existing plaintext file is only *going* to be encrypted", async () => { + // The transitional state: the passphrase is set, so the next write will + // encrypt, but the bytes on disk are still readable. Reporting "File + // (encrypted)" here is the one false reassurance this subsystem exists to + // avoid. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const plain = await loadWithProbe(false); + await plain.defaultSecretStore().set("alpha", "env:A", "1"); + + // Same file, now with a passphrase configured. + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + const withKey = await loadWithProbe(false); + const info = await withKey.getSecretStorageInfo(); + expect(info.plaintext).toBe(true); + expect(info.pendingEncryption).toBe(true); + + // And the advice changes with it: telling someone to set a passphrase + // they have already set is worse than saying nothing. + const { secretStorageCaveat, secretStorageLabel } = + await import("@inspector/core/auth/secret-storage-info.js"); + expect(secretStorageLabel(info)).toBe("File (unencrypted)"); + expect(secretStorageCaveat(info)).toMatch(/next time a secret is saved/); + }); + + it("says encrypted once the upgrading write has actually landed", async () => { + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const plain = await loadWithProbe(false); + await plain.defaultSecretStore().set("alpha", "env:A", "1"); + + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + const withKey = await loadWithProbe(false); + await withKey.defaultSecretStore().set("alpha", "env:B", "2"); + + const after = await loadWithProbe(false); + const info = await after.getSecretStorageInfo(); + expect(info.plaintext).toBe(false); + expect(info.pendingEncryption).toBeUndefined(); + }); + + it("falls back to the write policy when there is no file yet", async () => { + // Nothing on disk to describe, and the policy is then accurate: the first + // write creates the file in exactly that mode. + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "absent.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + const info = await mod.getSecretStorageInfo(); + expect(info.plaintext).toBe(false); + expect(info.pendingEncryption).toBeUndefined(); + }); }); diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index a61e2fc2..7c5e419d 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -32,8 +32,16 @@ * * **Key.** `MCP_INSPECTOR_SECRET_KEY` is a passphrase, not a key: it is * stretched with scrypt against a per-file random salt stored beside the - * ciphertext. That means a short, memorable value is safe to use, and it - * means the same passphrase produces a different key for a different file. + * ciphertext, so the same passphrase produces a different key for a + * different file and a precomputed table buys an attacker nothing. + * + * That is **not** a licence to use a short, memorable one. The salt + * defeats precomputation; it does nothing against guessing, and the cost + * parameters below are deliberately cheap (a few milliseconds, since the + * derivation runs on every read and write). Anyone who steals + * `secrets.json` can therefore try candidate passphrases quickly and + * offline. Use a high-entropy value — generated, not chosen — the same as + * you would for any other credential. * * **Availability contract — identical to `KeyringSecretStore`'s**, because * callers must not have to know which store they got. `get` is tolerant @@ -183,11 +191,47 @@ export class FileSecretStore implements SecretStore { this.passphrase = raw && raw.trim() ? raw : undefined; } - /** True when this store encrypts. Drives the `plaintext` descriptor flag. */ + /** + * True when the *next write* will encrypt. This is the configured policy, + * not the state of the file — see {@link readOnDiskEncryption}, which is + * what the user-facing descriptor is built from. + */ get encrypted(): boolean { return this.passphrase !== undefined; } + /** + * What the file on disk is *currently* written as — `null` when there is + * no file yet, or when it cannot be read or parsed. + * + * Separate from {@link encrypted} because the two genuinely disagree for a + * whole session: adding `MCP_INSPECTOR_SECRET_KEY` to an install that + * already has a plaintext file flips `encrypted` to true immediately, + * while the bytes stay readable until the next `set`. A descriptor built + * from the policy would tell that user "File (encrypted)" while their + * existing secrets sat in the clear — the precise reassurance this whole + * subsystem exists not to give. + * + * Reads the envelope only. It never decrypts, so it needs no passphrase + * and answers correctly even when the key is wrong or missing — which is + * also a state the UI has to describe rather than throw on. + */ + async readOnDiskEncryption(): Promise<SecretFile["encryption"] | null> { + try { + const raw = await readStoreFile(this.filePath); + if (raw === null) return null; + const parsed = JSON.parse(raw) as SecretFile; + return parsed.encryption === "none" || parsed.encryption === CIPHER + ? parsed.encryption + : null; + } catch { + // Absent, unreadable, or not JSON. "Unknown" is the honest answer, and + // the caller falls back to reporting the configured policy — the only + // thing that is knowable about a file nobody can read. + return null; + } + } + private async deriveKey(kdf: KdfParams): Promise<Buffer> { return scrypt( // Only reached when the passphrase is set — every caller checks first, @@ -221,10 +265,26 @@ export class FileSecretStore implements SecretStore { ); } + // Version first, before either branch. A `{ version: 2, encryption: + // "none" }` file is readable as far as the plaintext branch is concerned, + // but reading it means the next write rewrites it as version 1 — silently + // discarding whatever fields version 2 added. That is exactly the + // destroy-to-satisfy-an-additive-request case the encrypted branch already + // refuses, so it gets the same answer rather than a different one that + // happens to depend on which encryption mode the newer writer chose. + if (parsed.version !== FORMAT_VERSION) { + throw new SecretStoreUnavailableError( + `The secrets file at ${this.filePath} declares format version ${String(parsed.version)}, but this Inspector only understands version ${FORMAT_VERSION}. It was probably written by a newer Inspector; upgrade, or move the file aside to start over.`, + ); + } + if (parsed.encryption === "none") { // A plaintext file read by a store that now has a passphrase is fine and // deliberately not an error: the next write re-encrypts it, so setting - // the env var upgrades an existing file in place. + // the env var upgrades an existing file in place. Until that write + // lands the file really is still readable, which is why + // `readOnDiskEncryption` exists — the descriptor must report the file, + // not the intent. return { ...(parsed.secrets ?? {}) }; } diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index 9f07d903..a135d3d1 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -61,9 +61,19 @@ import { /** Env var naming an explicit store, bypassing the probe entirely. */ export const SECRET_STORE_ENV = "MCP_INSPECTOR_SECRET_STORE"; -/** Env var overriding the secrets file location. */ +/** Env var overriding the secrets file location outright. */ export const SECRET_FILE_ENV = "MCP_INSPECTOR_SECRET_FILE"; +/** + * Env var relocating the Inspector's durable state directory. Already + * honored by the web backend for OAuth tokens and `client.json`; the + * secrets file follows it for the same reason, and because the mount check + * below asks whether *that* directory survives. A container mounting only + * the configured storage directory would otherwise be judged unmounted, + * fall back to `memory`, and lose secrets the user had arranged to keep. + */ +export const STORAGE_DIR_ENV = "MCP_STORAGE_DIR"; + const KINDS: SecretStoreKind[] = ["keyring", "file", "memory"]; export interface ResolvedSecretStore { @@ -89,10 +99,16 @@ export function parseSecretStoreEnv( return undefined; } -/** Default secrets file: beside the catalog, in `~/.mcp-inspector`. */ +/** + * Default secrets file: `MCP_INSPECTOR_SECRET_FILE` if named outright, + * otherwise `secrets.json` in the configured storage directory, otherwise + * beside the catalog in `~/.mcp-inspector`. + */ export function defaultSecretFilePath(): string { const override = process.env[SECRET_FILE_ENV]?.trim(); if (override) return path.resolve(override); + const storageDir = process.env[STORAGE_DIR_ENV]?.trim(); + if (storageDir) return path.resolve(storageDir, "secrets.json"); const homeDir = process.env.HOME || process.env.USERPROFILE || "."; return path.join(homeDir, ".mcp-inspector", "secrets.json"); } @@ -172,11 +188,11 @@ export function isOnMountPoint(dir: string): boolean { } /** Build the store named by `kind`, plus the descriptor that explains it. */ -function buildStore( +async function buildStore( kind: SecretStoreKind, reason: SecretStorageInfo["reason"], detail?: string, -): ResolvedSecretStore { +): Promise<ResolvedSecretStore> { if (kind === "keyring") { return { store: new KeyringSecretStore(), @@ -196,13 +212,24 @@ function buildStore( // every failure), and blocking selection on a chmod would make an // unwritable file a startup problem rather than a `set`-time one. void tightenSecretFilePermissions(filePath); + // Report the FILE, not the intent. `store.encrypted` is what the next write + // will do; a file already on disk in the clear stays readable until that + // write happens, and saying "File (encrypted)" in the meantime is the one + // false reassurance this footer must never give. With no file yet (or one we + // cannot parse) the policy is all there is to report, and it is then + // accurate — the first write creates the file in that mode. + const onDisk = await store.readOnDiskEncryption(); + const plaintext = onDisk === null ? !store.encrypted : onDisk === "none"; return { store, info: { kind, reason, path: filePath, - plaintext: !store.encrypted, + plaintext, + // Only meaningful while the two disagree; omitted otherwise so the + // payload does not carry a flag that says nothing. + ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), durable: true, detail, }, @@ -260,7 +287,7 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> { resolved ??= (async (): Promise<ResolvedSecretStore> => { const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]); if (configured) { - const result = buildStore(configured, "configured"); + const result = await buildStore(configured, "configured"); warnAboutSecretStorage(result.info); return result; } @@ -271,7 +298,7 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> { container: isContainer(), mounted: isOnMountPoint(path.dirname(defaultSecretFilePath())), }); - const result = buildStore(kind, "fallback", probe.detail); + const result = await buildStore(kind, "fallback", probe.detail); warnAboutSecretStorage(result.info); return result; })(); diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index 054cf3b4..38c9ca79 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -42,12 +42,26 @@ export interface SecretStorageInfo { */ path?: string; /** - * True when a file-backed store is writing secrets in the clear (no - * `MCP_INSPECTOR_SECRET_KEY`). Always false for the other kinds — a - * keychain is opaque to us and RAM is not "at rest" in the sense this - * flag is about. + * True when the secrets file is *currently* in the clear. Read off the + * file's own envelope rather than off whether a passphrase is + * configured, because those two disagree for a whole session: adding + * `MCP_INSPECTOR_SECRET_KEY` to an install that already has a plaintext + * file makes the next write encrypt, while the existing bytes stay + * readable until then. Reporting the intent would tell that user their + * secrets were encrypted while they were not. + * + * Always false for the other kinds — a keychain is opaque to us and RAM + * is not "at rest" in the sense this flag is about. */ plaintext?: boolean; + /** + * True in exactly the transitional state above: `plaintext` is still + * true, but a passphrase is set and the next write will encrypt. It + * changes the *advice* rather than the verdict — "set a passphrase" is + * the wrong thing to tell someone who already has, so the caveat says + * what will actually clear the condition. + */ + pendingEncryption?: boolean; /** * True when secrets outlive the process. False only for `memory`, and * it is the *promise* being made rather than an implementation detail: @@ -104,7 +118,9 @@ export function secretStorageCaveat( return "Secrets are not written anywhere and are lost when the Inspector exits."; } if (info.plaintext) { - return "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them."; + return info.pendingEncryption + ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." + : "Secrets are stored unencrypted (file mode 0600). Set MCP_INSPECTOR_SECRET_KEY to encrypt them."; } return undefined; } diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index a17ecbc8..88c26c5c 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -71,15 +71,34 @@ interface ConfigPayload { * * The footer states, in the UI, where a user's secret is about to go, so a * malformed or partial descriptor must render as "unknown" rather than as a - * confident half-answer — the `kind` alone is what every label and tone - * derives from, so it is the field the check turns on. + * confident half-answer. + * + * Every field the footer reads is checked, not just `kind`. A partial + * `{ kind: "file" }` would otherwise pass, and because a missing `plaintext` + * is falsy it would render as the quiet, neutral *encrypted* file — the + * single most misleading thing this component can say, produced by the + * absence of information rather than by any claim the backend made. Each + * kind's own required fields are checked for the same reason: a file store + * with no `path` cannot answer the question a file store exists to answer. */ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { if (typeof value !== "object" || value === null) return undefined; - const kind = (value as { kind?: unknown }).kind; - if (kind !== "keyring" && kind !== "file" && kind !== "memory") { + const v = value as Record<string, unknown>; + if (v.kind !== "keyring" && v.kind !== "file" && v.kind !== "memory") { return undefined; } + if ( + v.reason !== "configured" && + v.reason !== "default" && + v.reason !== "fallback" + ) { + return undefined; + } + if (typeof v.durable !== "boolean") return undefined; + if (v.kind === "file") { + if (typeof v.path !== "string" || v.path === "") return undefined; + if (typeof v.plaintext !== "boolean") return undefined; + } return value as SecretStorageInfo; } From de7795ca54419120339f15a4f017e1621576cb69 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 12:51:14 -0400 Subject: [PATCH 232/941] fix(auth): address Copilot review round 2 on the secret-store PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings, all taken. - **The descriptor went stale after the upgrading write.** Round 1 made it read the file instead of the policy, but it was still computed once and cached beside the selection — so the first `set` under a newly-set passphrase encrypted the file while `/api/config` kept serving "still unencrypted" for the rest of the session, describing bytes this very process had just changed. The *selection* stays cached (the banner, the payload, and the store doing the writing must agree on which store it is); the file descriptor is re-derived per call, which costs one read of a small file per config fetch. - **The mount check asked the wrong question.** Comparing a directory's `st_dev` against its parent's detects a mount *at* that path, but a volume mounted at `/home/node` leaves `/home/node/.mcp-inspector` an ordinary subdirectory sharing its parent's device — so a durable container setup was judged unmounted and demoted to session-only `memory`. It now reads `/proc/self/mountinfo` and asks whether the directory is *under* any mount other than `/`, which also catches the same-device bind mount no `st_dev` test can see. Where mountinfo is unreadable (macOS, Windows, a `/proc`-less container) it falls back to the device comparison, now against `/` rather than the parent. - **A decrypted payload was cast, not checked.** GCM proves the bytes are authentic, not that they parse to the right shape. An array passes `typeof === "object"`, takes the named assignment, and then `JSON.stringify` drops every named property of an array — so `set` would resolve having written a file without the secret it was handed. Silent loss on the one operation whose entire contract is that it does not lose things. Both branches now validate a non-array object of string values, and a shape refusal is deliberately not rethrown as a key mismatch, which would send the user to fix a working passphrase. - **The read-modify-write cycle was only serialized in-process.** A durable file is shared state and a second Inspector on the same box (a CLI run beside a web session) could read the same map and overwrite the first one's save, both reporting success. Mutations now take a `mkdir`-based lock. A lock left by a killed process is stolen after 10s — one `docker kill` must not brick secret writes permanently, which would be a worse failure than the race. If the lock cannot be created at all, the write proceeds unlocked rather than denying a real user a real secret to prevent a race with a process that may not exist. Reads do not lock: `writeStoreFile` is atomic, so a reader sees the old file or the new one, never a torn one. - **The permission repair was fire-and-forget while the docs promised 0600.** A file owned by another user, or on a read-only mount, stayed exposed while the footer described it as protected. It now verifies the resulting mode and warns, naming the actual mode, when it could not be tightened. - **`Group`'s theme carried a domain-named `secretStorageFooter` variant**, which AGENTS.md reserves for app-wide primitive styling. Renamed to the structural `stickyModalFooter`; the secret-storage meaning stays in the element that owns it. - The PR description still claimed the salt made a short passphrase safe, contradicting the corrected comment and README. Updated. Tests: both mount layouts that motivated the change (mount-at and mount-above) plus the sibling-prefix, space-escaped, first-run, and no-mountinfo cases; the array and non-string refusals on both the plaintext and the authentic-ciphertext paths (the latter through a hand-built fixture, since the store's own writer cannot produce that state); lock exclusion, stale-lock stealing, the un-lockable fallback, and two independent store instances racing; the permission report in all three states; and the descriptor no longer going stale within one process. Full `npm run ci` green — 5,688 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 4 + .../SecretStorageFooter.tsx | 2 +- .../auth/node/file-secret-store.test.ts | 226 +++++++++++++++- .../auth/node/secret-store-selection.test.ts | 136 ++++++++++ clients/web/src/theme/Group.ts | 8 +- core/auth/node/file-secret-store.ts | 241 +++++++++++++++--- core/auth/node/secret-store-selection.ts | 195 ++++++++++---- 7 files changed, 716 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index eb0624d7..73ffcd10 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,10 @@ docker run --rm -p 127.0.0.1:6274:6274 \ Setting the passphrase later is safe — the next write upgrades an existing plaintext file in place. Until that write happens the existing values really are still readable, and the banner and footer keep saying so rather than reporting the file as encrypted the moment the variable appears. **Changing or losing the passphrase is not safe**: a file that can no longer be decrypted is read as empty and _refuses to be written_, rather than being silently replaced with a new one holding only your latest secret. Restore the original passphrase, or delete `secrets.json` and re-enter the values. +The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true. + +**One durable file, one writer.** Mutations take a lock (`secrets.json.lock`) so a second Inspector on the same box — a CLI run beside a web session — cannot read the same state and overwrite the first one's save. A lock left behind by a killed process is treated as abandoned after 10 seconds and stolen, because a lock that only a clean exit can release would turn one `docker kill` into permanently broken secret writes. + Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. **Upgrading from an image before this fix?** Earlier images did not create `/home/node/.mcp-inspector`, so Docker created the volume's mount point as `root` and the non-root `node` user couldn't write to it. An **empty** volume repairs itself on the first run of a current image (Docker applies the image directory's ownership to an empty volume), but one that already has files in it keeps its old `root` ownership and still fails with `EACCES`. Fix it once: diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 008787f8..8bd445ba 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -32,7 +32,7 @@ export interface SecretStorageFooterProps { // header, body and footer as one. `nowrap` keeps it to one line at any modal // width; the truncating detail text on the right absorbs a narrow modal. const FooterBand = Group.withProps({ - variant: "secretStorageFooter", + variant: "stickyModalFooter", h: SECRET_STORAGE_FOOTER_HEIGHT, px: "md", gap: 6, diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 369546fa..26e433a3 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -7,11 +7,13 @@ * not contain the plaintext — and a mocked filesystem can be made to * agree with any of those without them being true. */ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { + acquireLock, FileSecretStore, SecretFileKeyMismatchError, tightenSecretFilePermissions, @@ -447,10 +449,52 @@ describe("tightenSecretFilePermissions", () => { expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600); }); - it("is a no-op for a file that doesn't exist yet (the first-run case)", async () => { + it("reports absent, not a failure, for a file that doesn't exist yet", async () => { + // The first-run case. Nothing to tighten and nothing to warn about — + // `writeStoreFile` creates the file 0600. await expect( tightenSecretFilePermissions(path.join(tmpDir, "nope.json")), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ state: "absent" }); + }); + + it("reports a mode it could not fix, instead of claiming 0600", async () => { + // The case the caller has to hear about: the docs and the footer both say + // the file is 0600, so a file we failed to tighten must be surfaced rather + // than silently described as protected. + // + // Driven through a fresh module whose `chmod` rejects — a test cannot + // readily own a file it lacks permission to chmod, and vitest cannot spy + // on a `node:fs/promises` export because the ESM namespace is not + // configurable. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + await fs.chmod(filePath(), 0o644); + + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + return { + ...actual, + default: actual, + chmod: vi.fn(() => + Promise.reject(new Error("EPERM: operation not permitted")), + ), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/file-secret-store.js"); + expect(await mod.tightenSecretFilePermissions(filePath())).toEqual({ + state: "loose", + mode: 0o644, + }); + } finally { + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } }); }); @@ -498,3 +542,179 @@ describe("readOnDiskEncryption", () => { expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm"); }); }); + +/** + * Write a genuinely-encrypted secrets file whose decrypted payload is + * `payload` — including payloads the store would never produce. + * + * Hand-rolled against the documented format rather than driven through the + * store, because the whole point is to reach a state the store's own writer + * cannot create: an authentic ciphertext (right passphrase, valid GCM tag) + * carrying the wrong shape. Anything less would test the key check instead + * of the shape check. + */ +async function writeEncryptedFixtureWithPayload( + payload: unknown, + passphrase: string, +): Promise<void> { + const salt = crypto.randomBytes(16); + const kdf = { + algorithm: "scrypt", + salt: salt.toString("base64"), + N: 16384, + r: 8, + p: 1, + }; + const key: Buffer = await new Promise((resolve, reject) => { + crypto.scrypt( + passphrase, + salt, + 32, + { N: kdf.N, r: kdf.r, p: kdf.p }, + (err, k) => (err ? reject(err) : resolve(k)), + ); + }); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + const body = Buffer.concat([ + cipher.update(JSON.stringify(payload), "utf-8"), + cipher.final(), + ]); + const data = [iv, cipher.getAuthTag(), body] + .map((b) => b.toString("base64")) + .join("."); + await fs.writeFile( + filePath(), + JSON.stringify({ version: 1, encryption: "aes-256-gcm", kdf, data }), + "utf-8", + ); +} + +describe("payload shape validation", () => { + // GCM proves who wrote the bytes, not what shape they parse to, and the + // plaintext branch proves nothing at all — so neither may be cast. + it("refuses a plaintext file whose secrets field is an array", async () => { + // The array is the case a cast cannot survive: it passes `typeof + // "object"`, takes the named assignment, and then `JSON.stringify` drops + // every named property — so `set` would resolve having written a file + // without the secret it was handed. + await fs.writeFile( + filePath(), + JSON.stringify({ version: 1, encryption: "none", secrets: [] }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:A", "1")).rejects.toThrow(/an array/); + expect(await store.get("alpha", "env:A")).toBe(null); + }); + + it("refuses a plaintext file holding a non-string value", async () => { + await fs.writeFile( + filePath(), + JSON.stringify({ + version: 1, + encryption: "none", + secrets: { "alpha:env:A": { nested: true } }, + }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + await expect(store.set("alpha", "env:B", "1")).rejects.toThrow( + /non-string value for "alpha:env:A"/, + ); + }); + + it("refuses an authentic ciphertext that decodes to the wrong shape", async () => { + // Encrypted with the right passphrase, so it decrypts cleanly and the + // refusal can only come from the shape check. It must NOT surface as a key + // mismatch, which would send the user to fix a passphrase that is working. + await writeEncryptedFixtureWithPayload([], "hunter2"); + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + expect(await store.get("alpha", "env:A")).toBe(null); + const err = await store + .set("alpha", "env:A", "1") + .then(() => null) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(SecretStoreUnavailableError); + expect(err).not.toBeInstanceOf(SecretFileKeyMismatchError); + expect((err as Error).message).toMatch(/an array/); + }); +}); + +describe("acquireLock", () => { + it("excludes a second holder while the first is live", async () => { + const target = path.join(tmpDir, "locked.json"); + const release = await acquireLock(target); + let secondTaken = false; + const second = acquireLock(target).then((r) => { + secondTaken = true; + return r; + }); + // Still held: the waiter must not have been let through. + await new Promise((r) => setTimeout(r, 60)); + expect(secondTaken).toBe(false); + await release(); + await ( + await second + )(); + expect(secondTaken).toBe(true); + }); + + it("steals a lock left behind by a killed process", async () => { + // A container killed with SIGKILL leaves the directory. A lock only a + // clean exit can release would turn that into permanently broken writes — + // strictly worse than the race it prevents. + const target = path.join(tmpDir, "stale.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + const release = await acquireLock(target); + await release(); + }); + + it("proceeds unlocked rather than failing when the lock cannot be created", async () => { + // A path whose parent does not exist: mkdir fails with ENOENT, not + // EEXIST. Refusing to write here would deny a real user a real secret to + // prevent a race with a second process that may not exist. + const release = await acquireLock( + path.join(tmpDir, "no-such-dir", "secrets.json"), + ); + await release(); + }); + + it("serializes concurrent writers through two independent store instances", async () => { + // Two `FileSecretStore` objects have separate in-process queues, so this + // is the cross-process race in miniature: without the file lock the second + // write drops the first one's entry. + const target = path.join(tmpDir, "shared.json"); + const a = new FileSecretStore({ filePath: target }); + const b = new FileSecretStore({ filePath: target }); + await Promise.all([ + a.set("alpha", "env:A", "1"), + b.set("alpha", "env:B", "2"), + ]); + expect(await a.get("alpha", "env:A")).toBe("1"); + expect(await a.get("alpha", "env:B")).toBe("2"); + }); +}); + +describe("tightenSecretFilePermissions reports what it could not fix", () => { + it("says absent when there is no file yet", async () => { + expect(await tightenSecretFilePermissions(filePath())).toEqual({ + state: "absent", + }); + }); + + it("says ok once the file is 0600", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + await fs.chmod(filePath(), 0o644); + expect(await tightenSecretFilePermissions(filePath())).toEqual({ + state: "ok", + }); + }); +}); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index b77dec18..ffc0f24f 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -221,6 +221,105 @@ describe("isOnMountPoint", () => { }); }); +describe("isOnMountPoint via /proc/self/mountinfo", () => { + /** + * Load the module with a synthetic mount table, so the container layouts + * that matter can be exercised on a developer's laptop. `existsSync` is + * stubbed alongside it because the paths in these tables do not exist here. + */ + async function loadWithMounts(mountinfo: string | null, existing: string[]) { + vi.resetModules(); + vi.doMock("node:fs", async () => { + const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); + return { + ...actual, + existsSync: (p: string) => existing.includes(p), + readFileSync: (p: string, enc?: unknown) => { + if (p === "/proc/self/mountinfo") { + if (mountinfo === null) throw new Error("ENOENT"); + return mountinfo; + } + return actual.readFileSync(p, enc as never); + }, + }; + }); + return import("@inspector/core/auth/node/secret-store-selection.js"); + } + + afterEach(() => { + vi.doUnmock("node:fs"); + vi.resetModules(); + }); + + // Field 5 is the mount point; the rest is real mountinfo shape. + const line = (point: string) => + `36 35 0:32 / ${point} rw,relatime - overlay overlay rw`; + + it("sees a directory that IS the mount point", async () => { + const mod = await loadWithMounts( + [line("/"), line("/home/node/.mcp-inspector")].join("\n"), + ["/home/node/.mcp-inspector"], + ); + expect(mod.isOnMountPoint("/home/node/.mcp-inspector")).toBe(true); + }); + + it("sees a directory INSIDE a mounted volume", async () => { + // The case a device-boundary test gets wrong: the volume is at + // /home/node, so .mcp-inspector is an ordinary subdirectory sharing its + // parent's device. Judging it unmounted demotes a durable setup to + // session-only memory. + const mod = await loadWithMounts( + [line("/"), line("/home/node")].join("\n"), + ["/home/node/.mcp-inspector"], + ); + expect(mod.isOnMountPoint("/home/node/.mcp-inspector")).toBe(true); + }); + + it("is false on the container's writable layer", async () => { + // Only `/` is mounted, and `/` is deliberately excluded — it is the + // writable layer this question exists to distinguish against. + const mod = await loadWithMounts(line("/"), ["/home/node/.mcp-inspector"]); + expect(mod.isOnMountPoint("/home/node/.mcp-inspector")).toBe(false); + }); + + it("does not mistake a sibling prefix for a parent mount", async () => { + // `/home/node-2` starts with `/home/node` as a string but is not under + // it, which is why the check appends the separator. + const mod = await loadWithMounts( + [line("/"), line("/home/node")].join("\n"), + ["/home/node-2/.mcp-inspector"], + ); + expect(mod.isOnMountPoint("/home/node-2/.mcp-inspector")).toBe(false); + }); + + it("decodes a space-escaped mount point", async () => { + const mod = await loadWithMounts( + [line("/"), line("/mnt/my\\040volume")].join("\n"), + ["/mnt/my volume/secrets"], + ); + expect(mod.isOnMountPoint("/mnt/my volume/secrets")).toBe(true); + }); + + it("answers from the nearest existing ancestor, not the missing leaf", async () => { + // First run: the secrets directory has not been created inside the + // mounted volume yet. + const mod = await loadWithMounts( + [line("/"), line("/home/node")].join("\n"), + ["/home/node"], + ); + expect(mod.isOnMountPoint("/home/node/.mcp-inspector")).toBe(true); + }); + + it("falls back to the device comparison where mountinfo is unreadable", async () => { + // macOS, Windows, or a /proc-less container. The fallback compares + // against `/` rather than the immediate parent, so a subdirectory of a + // mount is still recognized — but a real path is needed for `stat`, so + // this only asserts it does not throw and answers the ordinary case. + const mod = await loadWithMounts(null, [tmpDir]); + expect(mod.isOnMountPoint(tmpDir)).toBe(false); + }); +}); + describe("warnAboutSecretStorage", () => { const base: SecretStorageInfo = { kind: "keyring", @@ -452,6 +551,43 @@ describe("the descriptor reports the file, not the write policy", () => { expect(info.pendingEncryption).toBeUndefined(); }); + it("stops saying unencrypted once this process performs the upgrading write", async () => { + // The staleness trap: the selection is cached for the process, so a + // descriptor cached alongside it would keep serving "still unencrypted" + // from `/api/config` for the rest of the session — describing bytes this + // very process had just changed. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const plain = await loadWithProbe(false); + await plain.defaultSecretStore().set("alpha", "env:A", "1"); + + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + const mod = await loadWithProbe(false); + + // Same module instance throughout — this is one running process. + expect((await mod.getSecretStorageInfo()).plaintext).toBe(true); + await mod.defaultSecretStore().set("alpha", "env:B", "2"); + const after = await mod.getSecretStorageInfo(); + expect(after.plaintext).toBe(false); + expect(after.pendingEncryption).toBeUndefined(); + }); + + it("keeps returning the same store while re-deriving the descriptor", async () => { + // The selection itself must stay cached — the banner, /api/config, and + // the store doing the writing have to agree on *which* store it is. + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + const first = await mod.resolveSecretStore(); + const second = await mod.resolveSecretStore(); + expect(second.store).toBe(first.store); + expect((await mod.getSecretStorageInfo()).kind).toBe("file"); + }); + it("falls back to the write policy when there is no file yet", async () => { // Nothing on disk to describe, and the policy is then accurate: the first // write creates the file in exactly that mode. diff --git a/clients/web/src/theme/Group.ts b/clients/web/src/theme/Group.ts index d6e5b0df..9f16e716 100644 --- a/clients/web/src/theme/Group.ts +++ b/clients/web/src/theme/Group.ts @@ -14,7 +14,7 @@ export const ThemeGroup = Group.extend({ return {}; }, styles: (_theme, props) => { - // `secretStorageFooter` is the permanent band at the bottom of the + // `stickyModalFooter` is the permanent band at the bottom of the // Client/Server Settings modals naming where secrets are stored (#1950). // Flat properties, so they belong here rather than in App.css. // @@ -27,7 +27,11 @@ export const ThemeGroup = Group.extend({ // this is supposed to be, so it sticks to the bottom edge instead, with // the same opaque body background and z-index the sticky header uses so // content passes underneath rather than through it. - if (props.variant === "secretStorageFooter") { + // Structural, not domain: this says "a band pinned to the bottom of a + // modal", which is all the theme layer should know. What the band *means* + // — that it names where your secrets go — belongs to `SecretStorageFooter`, + // the element that owns those semantics. + if (props.variant === "stickyModalFooter") { return { root: { position: "sticky", diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 7c5e419d..5ab95096 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -122,6 +122,37 @@ interface SecretFile { const buildAccount = (serverId: string, field: string): string => `${serverId}:${field}`; +/** + * Assert that a decoded payload really is a `Record<string, string>`. + * + * Neither branch of {@link FileSecretStore.readMap} can trust its input: the + * plaintext `secrets` field is whatever the file says, and a decrypted + * payload only proves the *bytes* were authentic — GCM verifies who wrote + * them, not what shape they parse to. + * + * An array is the case that shows why a cast is not enough. `[]` passes + * `typeof === "object"`, so `map[account] = value` assigns a named property + * to it, and `JSON.stringify` then drops every named property of an array — + * so `set` resolves successfully having written a file that does not contain + * the secret it was given. Silent loss on the one operation whose entire + * contract is that it does not lose things. + */ +function asSecretMap(value: unknown, filePath: string): Record<string, string> { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new SecretStoreUnavailableError( + `The secrets file at ${filePath} does not hold a secret map (found ${Array.isArray(value) ? "an array" : typeof value}). Refusing to write, which would replace it. Move the file aside to start over.`, + ); + } + for (const [key, entry] of Object.entries(value)) { + if (typeof entry !== "string") { + throw new SecretStoreUnavailableError( + `The secrets file at ${filePath} holds a non-string value for "${key}". Refusing to write, which would replace it. Move the file aside to start over.`, + ); + } + } + return { ...(value as Record<string, string>) }; +} + /** * Thrown when a file was written encrypted and the current passphrase * cannot open it. A distinct type so `set` can refuse with advice that @@ -161,6 +192,83 @@ function decodeParts( }; } +/** + * How long a lock may be held before another process treats it as + * abandoned. Generous next to the work it guards (a read, one scrypt + * derivation, an atomic write — single-digit milliseconds), because the + * cost of being wrong is asymmetric: waiting a second too long is + * invisible, while stealing a live lock reintroduces the very race the + * lock exists to close. + */ +const LOCK_STALE_MS = 10_000; +/** Total time to wait for a lock before giving up and reporting it. */ +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_POLL_MS = 25; + +const sleep = (ms: number): Promise<void> => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Take an exclusive, cross-process lock on `filePath`. + * + * `mkdir` is the primitive rather than a lock *file*: it is atomic and + * fails with `EEXIST` on every POSIX filesystem and on Windows, and it + * needs no `O_EXCL` open-flag juggling. Returns the release function. + * + * **Stale locks are stolen, and that is not optional.** A container killed + * with SIGKILL leaves the directory behind, and a lock that can only be + * released by a cooperative exit would make one `docker kill` brick secret + * writes permanently — turning a rare lost-write race into a permanent + * outage, which is a strictly worse failure. Staleness is judged by the + * directory's mtime. + * + * Never throws for lock-infrastructure reasons alone. If the lock cannot + * be created at all (a read-only mount, a filesystem without mkdir + * semantics we can rely on), the caller proceeds unlocked: the in-process + * queue still protects the common single-process case, and refusing to + * write would deny a real user a real secret in order to prevent a race + * with a second process that may not exist. + */ +export async function acquireLock( + filePath: string, +): Promise<() => Promise<void>> { + const lockDir = `${filePath}.lock`; + const noop = async (): Promise<void> => {}; + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + for (;;) { + try { + await fs.mkdir(lockDir, { recursive: false }); + return async () => { + try { + await fs.rmdir(lockDir); + } catch { + // Already gone — another process judged us stale and stole it. + // Nothing to undo, and nothing the caller can act on. + } + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + // Not "someone else holds it" — the lock itself is unavailable. + // Proceed unlocked rather than failing the write; see above. + return noop; + } + try { + const held = (await fs.stat(lockDir)).mtimeMs; + if (Date.now() - held > LOCK_STALE_MS) { + await fs.rmdir(lockDir); + continue; + } + } catch { + // The holder released it between our mkdir and our stat. Retry. + } + if (Date.now() >= deadline) return noop; + await sleep(LOCK_POLL_MS); + } + } +} + export interface FileSecretStoreOptions { /** Absolute path of the secrets file. */ filePath: string; @@ -285,7 +393,7 @@ export class FileSecretStore implements SecretStore { // lands the file really is still readable, which is why // `readOnDiskEncryption` exists — the descriptor must report the file, // not the intent. - return { ...(parsed.secrets ?? {}) }; + return asSecretMap(parsed.secrets ?? {}, this.filePath); } if (parsed.encryption !== CIPHER || !parsed.kdf || !parsed.data) { @@ -306,8 +414,12 @@ export class FileSecretStore implements SecretStore { decipher.update(parts.body), decipher.final(), ]).toString("utf-8"); - return JSON.parse(plain) as Record<string, string>; - } catch { + return asSecretMap(JSON.parse(plain), this.filePath); + } catch (err) { + // A shape refusal is about the *contents*, not the key — it decrypted + // fine. Rethrowing it as a key mismatch would send the user to fix a + // passphrase that is working. + if (err instanceof SecretStoreUnavailableError) throw err; // GCM authentication failure is what a wrong passphrase looks like, and // it is indistinguishable from a tampered/corrupt file — both mean "this // key does not open this file", which is what the message says. @@ -351,6 +463,31 @@ export class FileSecretStore implements SecretStore { await writeStoreFile(this.filePath, `${JSON.stringify(file, null, 2)}\n`); } + /** + * Run `fn` with the file to itself across *processes* as well. + * + * The in-process queue is necessary and not sufficient: a durable secrets + * file is shared state, and a second Inspector on the same box — a CLI run + * beside a web session is the ordinary case, not a contrived one — reads + * the same "before" map and then atomically replaces the file, dropping + * whatever the first process had just added. Both writes report success; + * one secret is simply gone. + * + * Only mutations take the lock. Reads deliberately do not: `writeStoreFile` + * is atomic (write-temp-then-rename), so a concurrent reader sees either the + * old file or the new one and never a torn one, and making every `get` + * contend on a lock would put a filesystem round-trip in front of the + * request path for no correctness gain. + */ + private async withFileLock<T>(fn: () => Promise<T>): Promise<T> { + const release = await acquireLock(this.filePath); + try { + return await fn(); + } finally { + await release(); + } + } + /** Run `fn` with the file to itself, so concurrent callers can't lose writes. */ private serialize<T>(fn: () => Promise<T>): Promise<T> { const run = this.queue.then(fn); @@ -380,27 +517,29 @@ export class FileSecretStore implements SecretStore { } async set(serverId: string, field: string, value: string): Promise<void> { - await this.serialize(async () => { - let map: Record<string, string>; - try { - map = (await this.readMap()) ?? {}; - } catch (err) { - // Already a typed refusal (unreadable file / key mismatch) — rethrow - // rather than re-wrapping, so the specific advice survives. - if (err instanceof SecretStoreUnavailableError) throw err; - throw new SecretStoreUnavailableError( - `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - map[buildAccount(serverId, field)] = value; - try { - await this.writeMap(map); - } catch (err) { - throw new SecretStoreUnavailableError( - `Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }); + await this.serialize(() => + this.withFileLock(async () => { + let map: Record<string, string>; + try { + map = (await this.readMap()) ?? {}; + } catch (err) { + // Already a typed refusal (unreadable file / key mismatch) — + // rethrow rather than re-wrapping, so the specific advice survives. + if (err instanceof SecretStoreUnavailableError) throw err; + throw new SecretStoreUnavailableError( + `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + map[buildAccount(serverId, field)] = value; + try { + await this.writeMap(map); + } catch (err) { + throw new SecretStoreUnavailableError( + `Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }), + ); } async delete(serverId: string, field: string): Promise<void> { @@ -419,17 +558,20 @@ export class FileSecretStore implements SecretStore { */ private async deleteWhere(match: (key: string) => boolean): Promise<void> { try { - await this.serialize(async () => { - const map = await this.readMap(); - if (!map) return; - const keys = Object.keys(map).filter(match); - if (keys.length === 0) return; - for (const key of keys) delete map[key]; - // An emptied map keeps the file rather than unlinking it: its presence - // is what records which encryption mode this install settled on, so a - // later write can't silently switch modes on the user. - await this.writeMap(map); - }); + await this.serialize(() => + this.withFileLock(async () => { + const map = await this.readMap(); + if (!map) return; + const keys = Object.keys(map).filter(match); + if (keys.length === 0) return; + for (const key of keys) delete map[key]; + // An emptied map keeps the file rather than unlinking it: its + // presence is what records which encryption mode this install + // settled on, so a later write can't silently switch modes on the + // user. + await this.writeMap(map); + }), + ); } catch { // Intentionally silent — see the doc comment above. } @@ -450,11 +592,36 @@ export class FileSecretStore implements SecretStore { */ export async function tightenSecretFilePermissions( filePath: string, -): Promise<void> { +): Promise<SecretFilePermissionState> { try { await fs.chmod(filePath, 0o600); } catch { // Non-existent (the common case — nothing written yet), read-only mount, - // or a platform without POSIX modes. Nothing actionable here. + // or a platform without POSIX modes. The verification below decides + // whether that mattered. + } + // Verify rather than assume. The chmod above is best-effort, but the UI and + // the README both state the file is 0600 — so a file we failed to tighten + // (owned by another user, on a read-only mount) must be *reported*, not + // quietly described as protected. This is the one place that can tell the + // difference, because it is the only one that looks at the file. + try { + const mode = (await fs.stat(filePath)).mode & 0o777; + if (mode === 0o600) return { state: "ok" }; + return { state: "loose", mode }; + } catch { + // No file yet — the common first run. `writeStoreFile` creates it 0600, + // so there is nothing to warn about. + return { state: "absent" }; } } + +/** + * Outcome of {@link tightenSecretFilePermissions}: the file is `0600` + * (`ok`), does not exist yet (`absent`), or exists with a wider mode we + * could not fix (`loose`) — the only case anyone needs to hear about. + */ +export type SecretFilePermissionState = + | { state: "ok" } + | { state: "absent" } + | { state: "loose"; mode: number }; diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index a135d3d1..f447b0e3 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -145,44 +145,92 @@ export function isContainer(): boolean { } /** - * Is `dir` — or the nearest existing ancestor of it — a mount point? + * Nearest ancestor of `dir` that actually exists. * - * The test is `st_dev`: a mount is exactly the case where a directory sits - * on a different device from its parent. That is what makes this work - * regardless of *what* was mounted (named volume, bind mount, tmpfs) and - * without parsing `/proc/mounts`, whose format and container-namespace - * visibility vary. + * The directory usually does not exist on a first run — `~/.mcp-inspector` + * is created on demand — so every check below has to ask about the closest + * thing that does. A volume mounted *at* the target path exists by + * definition, so the common positive case needs no walk at all. + */ +function nearestExisting(dir: string): string | null { + let current = path.resolve(dir); + while (!fsSync.existsSync(current)) { + const parent = path.dirname(current); + /* v8 ignore next -- @preserve: the walk can only run out of ancestors if + the filesystem root itself does not exist, which would mean the process + has no cwd to have resolved against. Kept as the loop's termination + guard rather than trusting that invariant. */ + if (parent === current) return null; + current = parent; + } + return current; +} + +/** + * Mount points visible to this process, from `/proc/self/mountinfo`, or + * `null` where that file cannot be read (macOS, Windows, a `/proc`-less + * container). * - * It walks to the nearest existing ancestor because the directory usually - * does not exist yet on a first run — `~/.mcp-inspector` is created on - * demand. A volume mounted *at* that path exists by definition, so the - * common positive case is found immediately; the walk is what keeps the - * common negative case from being answered by a missing-file error. + * Field 5 of each line is the mount point, with spaces escaped as `\040`. + * `/` is dropped: in a container that is the writable layer, which is + * exactly what the durability question is asking *against*. + */ +function readMountPoints(): string[] | null { + try { + return fsSync + .readFileSync("/proc/self/mountinfo", "utf-8") + .split("\n") + .map((line) => line.split(" ")[4]) + .filter((point): point is string => Boolean(point)) + .map((point) => point.replace(/\\040/g, " ")) + .filter((point) => point !== "/"); + } catch { + return null; + } +} + +/** + * Does `dir` live on something mounted over the root filesystem? * - * `/` is its own parent and is trivially a "mount", so the loop stops - * there and reports false: the container's root filesystem is the writable - * layer this whole check exists to distinguish *against*. + * Two mechanisms, because either one alone gets a realistic case wrong. + * + * **`/proc/self/mountinfo` first**, where it exists. It answers the question + * actually being asked — "is this path *under* a mount" — rather than the + * narrower "is this path *itself* a mount". The difference is a real + * container layout: mounting a volume at `/home/node` leaves + * `/home/node/.mcp-inspector` an ordinary subdirectory sharing its parent's + * device, so a device-boundary test answers "not mounted" and demotes a + * durable setup to session-only `memory`. Reading the mount table also sees + * a bind mount that happens to come from the same device, which no `st_dev` + * comparison can. + * + * **The `st_dev` comparison as the fallback**, and against `/` rather than + * against the immediate parent — so a subdirectory of a mount is still + * recognized. Outside a container this barely matters (the fallback there is + * `file` either way); it matters in a `/proc`-less container, where being + * wrong costs durability. + * + * Errors answer `false`, keeping the fallback conservative — `memory` in a + * container — which never promises to keep a secret it then loses. */ export function isOnMountPoint(dir: string): boolean { try { - let current = path.resolve(dir); - // Walk up to the first path that exists. - while (!fsSync.existsSync(current)) { - const parent = path.dirname(current); - /* v8 ignore next -- @preserve: the walk can only run out of ancestors - if the filesystem root itself does not exist, which would mean the - process has no cwd to have resolved against. Kept as the loop's - termination guard rather than trusting that invariant. */ - if (parent === current) return false; - current = parent; + const current = nearestExisting(dir); + if (current === null) return false; + + const mounts = readMountPoints(); + if (mounts !== null) { + return mounts.some( + (point) => current === point || current.startsWith(`${point}/`), + ); } + const parent = path.dirname(current); - if (parent === current) return false; // reached `/` - return fsSync.statSync(current).dev !== fsSync.statSync(parent).dev; + if (parent === current) return false; // `dir` resolved to the root itself + return fsSync.statSync(current).dev !== fsSync.statSync("/").dev; } catch { // Permission denied, a racing unmount, or a platform where `dev` is not - // meaningful. "Not a mount" keeps the fallback on the conservative side - // (memory in a container), which never loses a secret it promised to keep. + // meaningful. return false; } } @@ -207,32 +255,54 @@ async function buildStore( } const filePath = defaultSecretFilePath(); const store = new FileSecretStore({ filePath }); - // Repair a loosened mode on a pre-existing file before anything reads or - // writes it. Fire-and-forget: it is a best-effort tightening (it swallows - // every failure), and blocking selection on a chmod would make an - // unwritable file a startup problem rather than a `set`-time one. - void tightenSecretFilePermissions(filePath); - // Report the FILE, not the intent. `store.encrypted` is what the next write - // will do; a file already on disk in the clear stays readable until that - // write happens, and saying "File (encrypted)" in the meantime is the one - // false reassurance this footer must never give. With no file yet (or one we - // cannot parse) the policy is all there is to report, and it is then - // accurate — the first write creates the file in that mode. + // Repair a loosened mode on a pre-existing file, and — unlike before — + // *verify* the result. The UI and the README both state the file is 0600, + // so a file we could not tighten (owned by another user, on a read-only + // mount) has to be reported rather than quietly described as protected. + const perms = await tightenSecretFilePermissions(filePath); + if (perms.state === "loose") { + console.warn( + `[mcp-inspector] The secrets file at ${filePath} is mode ${perms.mode.toString(8).padStart(4, "0")}, not 0600, and could not be tightened. Anyone who can read it can read the secrets in it.`, + ); + } + return { + store, + info: await describeFileStore(store, filePath, kind, reason, detail), + }; +} + +/** + * Build the descriptor for a file store by *looking at the file*. + * + * `store.encrypted` is what the next write will do; a file already on disk in + * the clear stays readable until that write happens, and saying "File + * (encrypted)" in the meantime is the one false reassurance this whole + * subsystem exists not to give. With no file yet (or one we cannot parse) the + * policy is all there is to report, and it is then accurate — the first write + * creates the file in that mode. + * + * Split out from `buildStore` so it can be re-run: this is a property of a + * file that changes underneath us, not a property of the selection. + */ +async function describeFileStore( + store: FileSecretStore, + filePath: string, + kind: SecretStoreKind, + reason: SecretStorageInfo["reason"], + detail?: string, +): Promise<SecretStorageInfo> { const onDisk = await store.readOnDiskEncryption(); const plaintext = onDisk === null ? !store.encrypted : onDisk === "none"; return { - store, - info: { - kind, - reason, - path: filePath, - plaintext, - // Only meaningful while the two disagree; omitted otherwise so the - // payload does not carry a flag that says nothing. - ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), - durable: true, - detail, - }, + kind, + reason, + path: filePath, + plaintext, + // Only meaningful while the two disagree; omitted otherwise so the + // payload does not carry a flag that says nothing. + ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), + durable: true, + detail, }; } @@ -305,9 +375,28 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> { return resolved; } -/** The active store's descriptor — for the banner and `GET /api/config`. */ +/** + * The active store's descriptor — for the banner and `GET /api/config`. + * + * The *selection* is cached, deliberately (see {@link resolveSecretStore}). + * The descriptor is not, for the file store: `plaintext` and + * `pendingEncryption` describe bytes on disk that this very process + * changes. Cache them and the first `set` under a newly-set passphrase + * encrypts the file while every subsequent `/api/config` keeps serving + * "still unencrypted" until a restart — stale in the safe direction, but + * stale about the one fact this surface exists to state. Re-deriving costs + * one read of a small file per config fetch, which is once per page load. + */ export async function getSecretStorageInfo(): Promise<SecretStorageInfo> { - return (await resolveSecretStore()).info; + const { store, info } = await resolveSecretStore(); + if (info.kind !== "file" || !(store instanceof FileSecretStore)) return info; + return describeFileStore( + store, + store.filePath, + "file", + info.reason, + info.detail, + ); } /** From b143dd6cbda636492d9adcda69fdeb80bbe705b1 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 13:19:56 -0400 Subject: [PATCH 233/941] fix(auth): address Copilot review round 3 on the secret-store PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- clients/web/server/server.ts | 5 + clients/web/server/vite-hono-plugin.ts | 5 + .../core/auth/secret-storage-info.test.ts | 20 +++ .../test/core/client/node-persistence.test.ts | 18 +++ .../test/core/react/useInitialConfig.test.tsx | 56 +++++++ .../auth/node/file-secret-store.test.ts | 101 +++++++++++- .../auth/node/secret-store-selection.test.ts | 69 ++++++++ .../mcp/remote/server-config.test.ts | 57 +++++++ .../mcp/remote/servers-route.test.ts | 67 ++++++++ core/auth/node/file-secret-store.ts | 148 ++++++++++++++---- core/auth/node/secret-store-selection.ts | 17 +- core/auth/node/secret-store.ts | 56 +++++++ core/auth/secret-storage-info.ts | 20 +++ core/client/node-persistence.ts | 9 ++ core/mcp/remote/node/server.ts | 41 ++++- core/react/useInitialConfig.ts | 23 +++ 16 files changed, 675 insertions(+), 37 deletions(-) diff --git a/clients/web/server/server.ts b/clients/web/server/server.ts index 39476682..2a10e59b 100644 --- a/clients/web/server/server.ts +++ b/clients/web/server/server.ts @@ -67,6 +67,11 @@ export async function startHonoServer( sandboxUrl: sandboxController.getUrl() ?? undefined, logger: config.logger, initialConfig: webServerConfigToInitialPayload(config, secretStorage), + // The startup value above is what the banner printed; the route + // re-resolves per request, because the first write under a newly-set + // passphrase encrypts a pre-existing plaintext file and a captured + // descriptor would keep reporting the old state until a restart. + secretStorageResolver: getSecretStorageInfo, }); const app = new Hono(); diff --git a/clients/web/server/vite-hono-plugin.ts b/clients/web/server/vite-hono-plugin.ts index 1be0674b..ab4a9fe4 100644 --- a/clients/web/server/vite-hono-plugin.ts +++ b/clients/web/server/vite-hono-plugin.ts @@ -86,6 +86,11 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin { sandboxUrl: sandboxController.getUrl() ?? undefined, logger: config.logger, initialConfig: webServerConfigToInitialPayload(config, secretStorage), + // The startup value above is what the banner printed; the route + // re-resolves per request, because the first write under a newly-set + // passphrase encrypts a pre-existing plaintext file and a captured + // descriptor would keep reporting the old state until a restart. + secretStorageResolver: getSecretStorageInfo, }); // Expose the resolved token to `transformIndexHtml`. Left empty when diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 1d60a92d..c21afe43 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -103,6 +103,26 @@ describe("secretStorageCaveat", () => { expect(caveat).not.toContain("MCP_INSPECTOR_SECRET_KEY"); }); + it("reports a mode it could not tighten, ahead of the encryption caveat", () => { + // A file other users can read is a live exposure; "unencrypted" is a + // property of a file only its owner can open. When both hold, the + // permission problem is the one to say out loud. + const loose: SecretStorageInfo = { ...plaintextFile, looseMode: 0o644 }; + expect(secretStorageTone(loose)).toBe("warn"); + expect(secretStorageCaveat(loose)).toContain("0644"); + expect(secretStorageCaveat(loose)).not.toContain( + "MCP_INSPECTOR_SECRET_KEY", + ); + }); + + it("warns about a loose mode even on an encrypted file", () => { + // The passphrase is then the only thing between a reader and the values, + // which is worth saying rather than rendering the quiet neutral state. + const loose: SecretStorageInfo = { ...encryptedFile, looseMode: 0o640 }; + expect(secretStorageTone(loose)).toBe("warn"); + expect(secretStorageCaveat(loose)).toContain("0640"); + }); + it("prefers the memory caveat when a memory store somehow carries the flag", () => { // Defensive ordering: `plaintext` is meaningless for memory, and the // durability loss is the more consequential of the two statements. diff --git a/clients/web/src/test/core/client/node-persistence.test.ts b/clients/web/src/test/core/client/node-persistence.test.ts index c0055925..7fadb821 100644 --- a/clients/web/src/test/core/client/node-persistence.test.ts +++ b/clients/web/src/test/core/client/node-persistence.test.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { InMemorySecretStore, + SessionSecretStore, KeychainUnavailableError, SECRET_FIELD_IDP_CLIENT_SECRET, type SecretStore, @@ -62,6 +63,23 @@ describe("client node-persistence", () => { ).toBe("plain"); }); + it("keeps the plaintext on disk when the store is session-scoped", async () => { + // The container fallback. Migrating here would delete a secret that + // survives restarts and keep only a copy that dies with the process — + // and it happens on an ordinary read, so merely loading the app would + // do it. The session still works: the value is loaded into the store. + const filePath = await makeTmpFile( + JSON.stringify(configWithPlaintextSecret), + ); + const secretStore = new SessionSecretStore(); + + const loaded = await readClientConfigStore(filePath, secretStore); + + expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain"); + // The disk copy is deliberately left alone. + expect(readFileSync(filePath, "utf-8")).toContain("plain"); + }); + it("does not overwrite an existing keychain secret during migration", async () => { const filePath = await makeTmpFile( JSON.stringify(configWithPlaintextSecret), diff --git a/clients/web/src/test/core/react/useInitialConfig.test.tsx b/clients/web/src/test/core/react/useInitialConfig.test.tsx index ac70b6a1..33523bbc 100644 --- a/clients/web/src/test/core/react/useInitialConfig.test.tsx +++ b/clients/web/src/test/core/react/useInitialConfig.test.tsx @@ -275,6 +275,26 @@ describe("useInitialConfig", () => { expect(result.current.secretStorage).toEqual(info); }); + it("passes through the optional file fields when they are well-typed", async () => { + const info = { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + pendingEncryption: true, + looseMode: 0o644, + }; + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ secretStorage: info })); + const { result } = renderHook(() => + useInitialConfig({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.secretStorage).toEqual(info); + }); + it("is undefined on a backend that omits the field", async () => { const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); const { result } = renderHook(() => @@ -310,6 +330,42 @@ describe("useInitialConfig", () => { "a descriptor with a non-boolean durable", { kind: "memory", reason: "fallback", durable: "no" }, ], + [ + // The truthy-string trap: the footer would read this as "already + // re-encrypting" and print advice that is the opposite of the truth. + "a stringly-typed pendingEncryption", + { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/x", + pendingEncryption: "false", + }, + ], + [ + "a non-numeric looseMode", + { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/x", + looseMode: "644", + }, + ], + [ + // File-only fields on a non-file kind mean the payload was not built + // by a backend we understand; rendering a mixture of two stores' + // answers is worse than rendering nothing. + "file fields on a memory descriptor", + { + kind: "memory", + reason: "fallback", + durable: false, + plaintext: true, + }, + ], ])("rejects %s", async (_label, value) => { const fetchFn = vi .fn() diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 26e433a3..2d9d1bd6 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -15,6 +15,7 @@ import * as path from "node:path"; import { acquireLock, FileSecretStore, + SecretFileLockTimeoutError, SecretFileKeyMismatchError, tightenSecretFilePermissions, } from "@inspector/core/auth/node/file-secret-store.js"; @@ -676,16 +677,102 @@ describe("acquireLock", () => { await release(); }); - it("proceeds unlocked rather than failing when the lock cannot be created", async () => { - // A path whose parent does not exist: mkdir fails with ENOENT, not - // EEXIST. Refusing to write here would deny a real user a real secret to - // prevent a race with a second process that may not exist. - const release = await acquireLock( - path.join(tmpDir, "no-such-dir", "secrets.json"), - ); + it("creates the parent directory so a first write is locked too", async () => { + // The first run has no secrets directory yet — `writeStoreFile` creates + // it later. Without creating it here, mkdir failed ENOENT and the lock + // was skipped, so two processes' *first* writes could both proceed + // unlocked and overwrite each other. First writes are exactly when two + // processes are most likely to start together. + const target = path.join(tmpDir, "no-such-dir", "secrets.json"); + const release = await acquireLock(target); + // A real lock was taken: a second acquisition must not be handed out. + let secondTaken = false; + void acquireLock(target).then((r) => { + secondTaken = true; + return r(); + }); + await new Promise((r) => setTimeout(r, 60)); + expect(secondTaken).toBe(false); await release(); }); + it("releases only a lock it still owns", async () => { + // A holder whose work outran the stale threshold must not delete the + // next owner's lock on its way out — that would drop a third writer into + // a section two processes believed they held. + const target = path.join(tmpDir, "owned.json"); + const lockDir = `${target}.lock`; + const release = await acquireLock(target); + // Simulate a steal: someone judged us stale, removed our lock, and took + // their own. + await fs.rm(lockDir, { recursive: true, force: true }); + await fs.mkdir(lockDir); + await fs.writeFile(path.join(lockDir, "owner"), "someone-else", "utf-8"); + await release(); + // Their lock is untouched. + expect(await fs.readFile(path.join(lockDir, "owner"), "utf-8")).toBe( + "someone-else", + ); + await fs.rm(lockDir, { recursive: true, force: true }); + }); + + it("fails the mutation rather than writing past a live holder", async () => { + // Falling through to an unlocked write under contention would be the + // worst possible moment for it: another writer is provably active. `set` + // is allowed to hard-fail, so this surfaces as the documented 503. + // + // The holder heartbeats faster than it goes stale, so the waiter can + // never mistake it for dead and must time out instead. + const target = path.join(tmpDir, "contended.json"); + const timings = { + staleMs: 400, + timeoutMs: 250, + heartbeatMs: 50, + pollMs: 10, + }; + const release = await acquireLock(target, timings); + try { + await expect(acquireLock(target, timings)).rejects.toBeInstanceOf( + SecretFileLockTimeoutError, + ); + } finally { + await release(); + } + }); + + it("does not steal from a holder that is merely slow", async () => { + // The heartbeat is what separates "dead" from "slow". Without it a + // holder on a slow filesystem loses its lock mid-section and the lost + // update comes straight back — so the waiter here must time out rather + // than acquire, even though it waits well past the stale threshold. + const target = path.join(tmpDir, "slow.json"); + const release = await acquireLock(target, { + staleMs: 120, + timeoutMs: 60_000, + heartbeatMs: 20, + pollMs: 10, + }); + try { + let stolen = false; + void acquireLock(target, { + staleMs: 120, + timeoutMs: 400, + pollMs: 10, + }) + .then((r) => { + stolen = true; + return r(); + }) + .catch(() => { + // Timed out, which is the expected outcome. + }); + await new Promise((r) => setTimeout(r, 500)); + expect(stolen).toBe(false); + } finally { + await release(); + } + }); + it("serializes concurrent writers through two independent store instances", async () => { // Two `FileSecretStore` objects have separate in-process queues, so this // is the cross-process race in miniature: without the file lock the second diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index ffc0f24f..0a5be897 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -333,6 +333,22 @@ describe("warnAboutSecretStorage", () => { expect(warn).not.toHaveBeenCalled(); }); + it("announces a fallback with no cause to name", () => { + // `detail` is optional — an explicitly configured store has no keychain + // error behind it, and the banner must not print an empty error line. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + warnAboutSecretStorage({ + kind: "file", + reason: "fallback", + durable: true, + path: "/x/secrets.json", + plaintext: false, + }); + const printed = warn.mock.calls.map((c) => String(c[0])).join("\n"); + expect(printed).toContain("fell back from the OS keychain"); + expect(printed).not.toContain("Keychain error:"); + }); + it("announces a fallback, names the cause, and points at the override", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); warnAboutSecretStorage({ @@ -503,6 +519,59 @@ describe("defaultSecretFilePath", () => { }); }); +describe("the descriptor reports a mode it could not tighten", () => { + it("carries looseMode into the descriptor and warns once at selection", async () => { + // The warning alone was not enough: the caveat under the secret field + // states the mode as fact, and on this box that statement was untrue + // while the browser had no way to know. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + await fs.writeFile( + filePath, + JSON.stringify({ version: 1, encryption: "none", secrets: {} }), + "utf-8", + ); + await fs.chmod(filePath, 0o644); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + return { + ...actual, + default: actual, + chmod: vi.fn(() => + Promise.reject(new Error("EPERM: operation not permitted")), + ), + }; + }); + vi.doMock("@inspector/core/auth/node/secret-store.js", async () => { + const actual = await vi.importActual< + typeof import("@inspector/core/auth/node/secret-store.js") + >("@inspector/core/auth/node/secret-store.js"); + return { + ...actual, + probeKeyringAvailable: vi.fn(async () => ({ available: true })), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/secret-store-selection.js"); + const info = await mod.getSecretStorageInfo(); + expect(info.looseMode).toBe(0o644); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("0644")); + } finally { + vi.doUnmock("node:fs/promises"); + vi.doUnmock("@inspector/core/auth/node/secret-store.js"); + vi.resetModules(); + } + }); +}); + describe("the descriptor reports the file, not the write policy", () => { it("says plaintext while a pre-existing plaintext file is only *going* to be encrypted", async () => { // The transitional state: the passphrase is set, so the next write will diff --git a/clients/web/src/test/integration/mcp/remote/server-config.test.ts b/clients/web/src/test/integration/mcp/remote/server-config.test.ts index a25d81f8..122ebf07 100644 --- a/clients/web/src/test/integration/mcp/remote/server-config.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-config.test.ts @@ -81,3 +81,60 @@ describe("createRemoteApp GET /api/config", () => { ).toBe(false); }); }); + +describe("GET /api/config re-resolves secretStorage per request (#1950)", () => { + it("serves the resolver's current answer, not the startup snapshot", async () => { + // The descriptor states where a secret lands, and it changes while the + // process runs — the first write under a newly-set passphrase encrypts a + // pre-existing plaintext file. A value captured at boot would keep + // telling every page load "still unencrypted" until a restart. + let plaintext = true; + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + initialConfig: { + defaultEnvironment: {}, + secretStorage: { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/tmp/secrets.json", + }, + }, + secretStorageResolver: async () => ({ + kind: "file", + reason: "fallback", + durable: true, + plaintext, + path: "/tmp/secrets.json", + }), + }); + + const first = (await ( + await app.request(new Request("http://test/api/config")) + ).json()) as { secretStorage?: { plaintext?: boolean } }; + expect(first.secretStorage?.plaintext).toBe(true); + + plaintext = false; // the upgrading write happens + const second = (await ( + await app.request(new Request("http://test/api/config")) + ).json()) as { secretStorage?: { plaintext?: boolean } }; + expect(second.secretStorage?.plaintext).toBe(false); + }); + + it("falls back to the startup payload when no resolver is supplied", async () => { + // Embedders and the test suite pass no resolver; they must keep the + // previous behaviour rather than losing the field. + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + initialConfig: { + defaultEnvironment: {}, + secretStorage: { kind: "keyring", reason: "default", durable: true }, + }, + }); + const body = (await ( + await app.request(new Request("http://test/api/config")) + ).json()) as { secretStorage?: { kind?: string } }; + expect(body.secretStorage?.kind).toBe("keyring"); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index 0a286253..a5f6f013 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -21,6 +21,7 @@ import { DEFAULT_SEED_CONFIG } from "@inspector/core/mcp/serverList.js"; import { InMemorySecretStore, KeychainUnavailableError, + SessionSecretStore, SECRET_FIELD_OAUTH_CLIENT_SECRET, envSecretField, type SecretStore, @@ -2622,3 +2623,69 @@ describe("/api/servers read-only sessions (#1481/#1483)", () => { } }); }); + +describe("plaintext migration against a session-scoped store (#1950)", () => { + // The container fallback selects an in-memory store in production. The + // migration must not take that as licence to delete the durable copy: it + // runs on an ordinary GET, so merely opening the app would move the user's + // secrets into RAM and lose them at exit. + let tempDir: string; + let configPath: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-session-store-")); + configPath = join(tempDir, "mcp.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + srv: { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", clientSecret: "must-survive" }, + }, + }, + }), + ); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("leaves the disk plaintext alone and still serves the secret", async () => { + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: configPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: new SessionSecretStore(), + }); + const before = readFileSync(configPath, "utf-8"); + + const res = await app.request(new Request("http://test/api/servers")); + expect(res.status).toBe(200); + const body = (await res.json()) as MCPConfig; + // The session behaves normally — the value is loaded and served. + expect( + (body.mcpServers.srv as { oauth?: { clientSecret?: string } }).oauth + ?.clientSecret, + ).toBe("must-survive"); + // But the durable copy is untouched. + expect(readFileSync(configPath, "utf-8")).toBe(before); + }); + + it("still strips it against a durable store, so the guard is the difference", async () => { + // The control: same config, same request, a store that outlives the + // process — and the migration does what it always did. + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: configPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: new InMemorySecretStore(), + }); + + const res = await app.request(new Request("http://test/api/servers")); + expect(res.status).toBe(200); + expect(readFileSync(configPath, "utf-8")).not.toContain("must-survive"); + }); +}); diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 5ab95096..29589d36 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -60,6 +60,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; +import * as path from "node:path"; import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; import { SecretStoreUnavailableError, @@ -201,13 +202,50 @@ function decodeParts( * lock exists to close. */ const LOCK_STALE_MS = 10_000; -/** Total time to wait for a lock before giving up and reporting it. */ -const LOCK_TIMEOUT_MS = 5_000; +/** + * How long to wait for a live lock. Deliberately longer than + * {@link LOCK_STALE_MS}, so a waiter always outlives the point at which the + * holder becomes stealable — with the shorter value it used to have, a + * waiter gave up before it could ever steal an abandoned lock, which is the + * one case the stealing exists for. + */ +const LOCK_TIMEOUT_MS = 15_000; const LOCK_POLL_MS = 25; +/** + * How often a holder touches its lock to prove it is alive. + * + * Comfortably under {@link LOCK_STALE_MS} so a live holder is never judged + * stale, which is what makes the two mechanisms coherent: staleness then + * means *dead*, not merely *slow*. Without the heartbeat, a holder on a + * slow filesystem — or one whose scrypt derivation ran long — has its lock + * stolen while it is still inside the critical section, reinstating exactly + * the lost update the lock exists to prevent. + */ +const LOCK_HEARTBEAT_MS = 3_000; const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Thrown when a lock is held by someone else for longer than + * {@link LOCK_TIMEOUT_MS}. + * + * A timeout must **fail the mutation**, never fall through and write + * anyway: proceeding unlocked past a live holder is precisely the + * lost-update the lock exists to prevent, and doing it under contention — + * the one moment another writer is provably active — would be the worst + * possible time. `set` is allowed to hard-fail by the store contract, so + * this surfaces as the documented 503 and the user retries. + */ +export class SecretFileLockTimeoutError extends SecretStoreUnavailableError { + constructor(filePath: string) { + super( + `Timed out waiting for another process to finish writing the secrets file at ${filePath}. Another Inspector may be busy; try again.`, + ); + this.name = "SecretFileLockTimeoutError"; + } +} + /** * Take an exclusive, cross-process lock on `filePath`. * @@ -215,56 +253,112 @@ const sleep = (ms: number): Promise<void> => * fails with `EEXIST` on every POSIX filesystem and on Windows, and it * needs no `O_EXCL` open-flag juggling. Returns the release function. * - * **Stale locks are stolen, and that is not optional.** A container killed - * with SIGKILL leaves the directory behind, and a lock that can only be - * released by a cooperative exit would make one `docker kill` brick secret - * writes permanently — turning a rare lost-write race into a permanent - * outage, which is a strictly worse failure. Staleness is judged by the - * directory's mtime. + * **Ownership is stamped, not assumed.** The holder writes a random token + * inside the lock directory and release only removes a lock still carrying + * that token. Without it, a holder whose work outran the stale threshold + * would delete the *next* owner's lock on its way out, letting a third + * writer into a critical section two processes believed they held — a + * rarer and much more confusing corruption than the one being prevented. * - * Never throws for lock-infrastructure reasons alone. If the lock cannot - * be created at all (a read-only mount, a filesystem without mkdir - * semantics we can rely on), the caller proceeds unlocked: the in-process - * queue still protects the common single-process case, and refusing to - * write would deny a real user a real secret in order to prevent a race - * with a second process that may not exist. + * **Stale locks are stolen, and that is not optional.** A container killed + * with SIGKILL leaves the directory behind, and a lock that only a + * cooperative exit can release would make one `docker kill` brick secret + * writes permanently — turning a rare lost write into a permanent outage, + * which is strictly worse. Staleness is judged by the directory's mtime, + * which a live holder refreshes on a heartbeat — so "stale" means the + * holder is *dead*, never merely slow. That distinction is what keeps the + * stealing from reinstating the very race the lock prevents. + * + * Throws {@link SecretFileLockTimeoutError} when another holder keeps it + * past the timeout. The *only* path that proceeds unlocked is a filesystem + * that cannot create the lock at all (a read-only mount, an exotic + * filesystem) — there, refusing would deny every write on a box where the + * single-process case is still perfectly safe, and a genuinely unwritable + * location fails at the write itself with a better message than a lock + * error would give. */ +/** + * Timing policy, overridable so the tests can exercise contention, + * staleness, and timeout without spending the real intervals. Production + * callers pass nothing and get the constants above. + */ +export interface LockTimings { + staleMs?: number; + timeoutMs?: number; + heartbeatMs?: number; + pollMs?: number; +} + export async function acquireLock( filePath: string, + timings: LockTimings = {}, ): Promise<() => Promise<void>> { + const staleMs = timings.staleMs ?? LOCK_STALE_MS; + const timeoutMs = timings.timeoutMs ?? LOCK_TIMEOUT_MS; + const heartbeatMs = timings.heartbeatMs ?? LOCK_HEARTBEAT_MS; + const pollMs = timings.pollMs ?? LOCK_POLL_MS; const lockDir = `${filePath}.lock`; + const ownerFile = `${lockDir}/owner`; + const token = crypto.randomBytes(16).toString("hex"); const noop = async (): Promise<void> => {}; - const deadline = Date.now() + LOCK_TIMEOUT_MS; + const deadline = Date.now() + timeoutMs; + + // The parent must exist before the lock can. On a first run it does not — + // `writeStoreFile` creates it later — so without this the very first write + // of every process failed with ENOENT and proceeded unlocked, meaning two + // first writes could both bypass the lock, read an empty map, and overwrite + // each other. The first write is exactly when two processes are most likely + // to start together. + try { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + } catch { + // Unwritable parent. The mkdir below will fail too, and the write after + // it will fail with a message about the actual problem. + } for (;;) { try { await fs.mkdir(lockDir, { recursive: false }); + await fs.writeFile(ownerFile, token, "utf-8"); + // Prove liveness for as long as we hold it. `unref` so a held lock + // never keeps the process alive on its own — the interval is a + // side-channel, not work anyone is waiting for. + const heartbeat = setInterval(() => { + const now = new Date(); + void fs.utimes(lockDir, now, now).catch(() => { + // Lock already released or stolen; the release below copes. + }); + }, heartbeatMs); + heartbeat.unref?.(); return async () => { + clearInterval(heartbeat); try { - await fs.rmdir(lockDir); + // Release only what we still own. A lock judged stale and stolen + // now belongs to someone else, and removing it would drop them + // into a race with whoever comes next. + const held = await fs.readFile(ownerFile, "utf-8"); + if (held !== token) return; + await fs.rm(lockDir, { recursive: true, force: true }); } catch { - // Already gone — another process judged us stale and stole it. - // Nothing to undo, and nothing the caller can act on. + // Already gone, or unreadable. Either way there is nothing to + // release and nothing the caller can act on. } }; } catch (err) { const code = (err as NodeJS.ErrnoException).code; - if (code !== "EEXIST") { - // Not "someone else holds it" — the lock itself is unavailable. - // Proceed unlocked rather than failing the write; see above. - return noop; - } + if (code !== "EEXIST") return noop; // cannot lock here at all — see above try { const held = (await fs.stat(lockDir)).mtimeMs; - if (Date.now() - held > LOCK_STALE_MS) { - await fs.rmdir(lockDir); + if (Date.now() - held > staleMs) { + await fs.rm(lockDir, { recursive: true, force: true }); continue; } } catch { // The holder released it between our mkdir and our stat. Retry. } - if (Date.now() >= deadline) return noop; - await sleep(LOCK_POLL_MS); + if (Date.now() >= deadline) + throw new SecretFileLockTimeoutError(filePath); + await sleep(pollMs); } } } diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index f447b0e3..637b7148 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -52,9 +52,10 @@ import { tightenSecretFilePermissions, } from "./file-secret-store.js"; import { - InMemorySecretStore, KeyringSecretStore, probeKeyringAvailable, + secretStoreIsDurable, + SessionSecretStore, type SecretStore, } from "./secret-store.js"; @@ -248,8 +249,12 @@ async function buildStore( }; } if (kind === "memory") { + // `SessionSecretStore`, not the bare `InMemorySecretStore` the tests + // use: it reports `isDurable() === false`, which is what stops the + // mcp.json / client.json migrations from stripping a plaintext secret + // off disk in exchange for a copy that dies with this process. return { - store: new InMemorySecretStore(), + store: new SessionSecretStore(), info: { kind, reason, durable: false, detail }, }; } @@ -293,11 +298,16 @@ async function describeFileStore( ): Promise<SecretStorageInfo> { const onDisk = await store.readOnDiskEncryption(); const plaintext = onDisk === null ? !store.encrypted : onDisk === "none"; + // Re-checked here rather than captured at selection, for the same reason + // the encryption state is: the mode can change under a running process, + // and this descriptor is what the browser renders as fact. + const perms = await tightenSecretFilePermissions(filePath); return { kind, reason, path: filePath, plaintext, + ...(perms.state === "loose" ? { looseMode: perms.mode } : {}), // Only meaningful while the two disagree; omitted otherwise so the // payload does not carry a flag that says nothing. ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), @@ -414,6 +424,9 @@ class DeferredSecretStore implements SecretStore { private async target(): Promise<SecretStore> { return (await resolveSecretStore()).store; } + async isDurable(): Promise<boolean> { + return secretStoreIsDurable(await this.target()); + } async get(serverId: string, field: string): Promise<string | null> { return (await this.target()).get(serverId, field); } diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 7944a23b..5bce41e4 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -301,6 +301,24 @@ const PROBE_ACCOUNT = "__inspector:probe"; * be lost. */ export interface SecretStore { + /** + * Do values written here outlive the process? + * + * Optional, and **absent means durable** — every store that predates + * #1950 is, and a custom one is far more likely to be backed by + * something than by RAM. + * + * It exists because the *migrations* need it. Both of them (mcp.json in + * `server.ts`, client.json in `client/node-persistence.ts`) lift a + * plaintext secret off disk, write it to this store, and then strip it + * from the file — a safe trade only while "written to the store" is at + * least as durable as "left on disk". `InMemorySecretStore` used to be + * a test double only, so that held by construction; making it a + * production fallback broke it, and a plain `GET /api/servers` on an + * unmounted container would have moved a user's existing secrets into + * RAM and lost them at exit. + */ + isDurable?(): Promise<boolean>; get(serverId: string, field: string): Promise<string | null>; set(serverId: string, field: string, value: string): Promise<void>; /** No-op if no entry exists. */ @@ -439,6 +457,21 @@ export class KeyringSecretStore implements SecretStore { * server factory. Mirrors the keyring contract exactly so swapping it * in/out doesn't change behavior beyond persistence. */ +/** + * Is `store` durable? Absent `isDurable` means yes — see the interface. + * + * A free function rather than a required method so the existing test + * doubles (and any third-party implementation) keep compiling, while the + * one store that is *not* durable has to say so explicitly. Defaulting the + * other way would make every double silently non-durable and quietly + * disable the migrations they exist to exercise. + */ +export async function secretStoreIsDurable( + store: SecretStore, +): Promise<boolean> { + return store.isDurable ? store.isDurable() : true; +} + export class InMemorySecretStore implements SecretStore { private readonly map = new Map<string, string>(); @@ -461,3 +494,26 @@ export class InMemorySecretStore implements SecretStore { } } } + +/** + * The in-memory store as a **production** choice — the fallback for a + * container with no keychain and nothing durable mounted (#1950). + * + * Behaviorally identical to {@link InMemorySecretStore}; it exists to + * answer `isDurable()` with `false`, and that one bit is load-bearing. + * Both migrations (mcp.json, client.json) lift a plaintext secret off + * disk, write it to the store, and then delete it from the file — safe + * only while the store outlives the process. Against a session-scoped + * store that trade destroys the secret, and it runs on an ordinary read, + * so merely opening the app would do it. + * + * Kept as a separate class rather than a flag on the base so the test + * suite's many `new InMemorySecretStore()` doubles keep standing in for a + * *working keychain*, which is what they are there to be. Only the code + * that deliberately chooses RAM in production says so. + */ +export class SessionSecretStore extends InMemorySecretStore { + async isDurable(): Promise<boolean> { + return false; + } +} diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index 38c9ca79..d1b42ab6 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -62,6 +62,16 @@ export interface SecretStorageInfo { * what will actually clear the condition. */ pendingEncryption?: boolean; + /** + * Octal mode of the secrets file when it is **not** `0600` and could not + * be tightened — a file owned by another user, or on a read-only mount. + * + * Carried into the descriptor rather than left in a startup log line + * because the caveat below states the mode as fact, at the point where a + * user types a secret. On such a box that statement was simply untrue, + * and the browser had no way to know. + */ + looseMode?: number; /** * True when secrets outlive the process. False only for `memory`, and * it is the *promise* being made rather than an implementation detail: @@ -103,6 +113,9 @@ export type SecretStorageTone = "neutral" | "warn"; export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone { if (info.kind === "memory") return "warn"; if (info.plaintext) return "warn"; + // An encrypted file that anyone can read is still worth flagging: the + // passphrase is the only thing standing between a reader and the values. + if (info.looseMode !== undefined) return "warn"; return "neutral"; } @@ -117,6 +130,13 @@ export function secretStorageCaveat( if (info.kind === "memory") { return "Secrets are not written anywhere and are lost when the Inspector exits."; } + // The permission problem outranks the encryption one when both hold: a + // file other users can read is a live exposure, while "unencrypted" is a + // property of a file only its owner can open. + if (info.looseMode !== undefined) { + const mode = info.looseMode.toString(8).padStart(4, "0"); + return `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`; + } if (info.plaintext) { return info.pendingEncryption ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." diff --git a/core/client/node-persistence.ts b/core/client/node-persistence.ts index 0d769d38..f1e83fcb 100644 --- a/core/client/node-persistence.ts +++ b/core/client/node-persistence.ts @@ -3,6 +3,7 @@ */ import { + secretStoreIsDurable, SecretStoreUnavailableError, type SecretStore, } from "../auth/node/secret-store.js"; @@ -55,6 +56,14 @@ async function migrateClientPlaintextSecret( value, ); } + // Only strip the plaintext once it is somewhere that outlives us. + // Against a session-scoped store (the container fallback added in + // #1950) this migration would trade a secret that survives restarts + // for one that dies with the process — and it runs on an ordinary + // read, so merely loading the app would destroy it. The value is + // still loaded into the store above, so this session behaves + // normally; only the delete is withheld. + if (!(await secretStoreIsDurable(secretStore))) return config; await writeStoreFile(filePath, serializeStore(stripped)); return stripped; } catch (err) { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4341954f..90241927 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -65,6 +65,7 @@ import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; import { + secretStoreIsDurable, SecretStoreUnavailableError, type SecretStore, } from "../../../auth/node/secret-store.js"; @@ -260,6 +261,15 @@ export interface RemoteServerOptions { * runners. */ secretStore?: SecretStore; + /** + * Re-resolve the secret-storage descriptor for each `GET /api/config`. + * + * A function rather than a value because the answer changes while the + * process runs (see the route). Optional: a backend that doesn't supply + * one falls back to whatever `initialConfig` carried, which is what the + * tests and any embedder that doesn't care get. + */ + secretStorageResolver?: () => Promise<SecretStorageInfo | undefined>; } export interface CreateRemoteAppResult { @@ -662,10 +672,22 @@ export function createRemoteApp( app.use("*", createAuthMiddleware(authToken)); } - app.get("/api/config", (c) => { + app.get("/api/config", async (c) => { + // `secretStorage` is re-resolved per request, not taken from the + // startup payload. It describes bytes on disk that this very process + // changes — the first `set` under a newly-set passphrase encrypts a + // pre-existing plaintext file — so a value captured at boot would keep + // telling every page load "still unencrypted" until a restart. That is + // stale in the safe direction, but stale about the single fact this + // field exists to state, which makes re-reading a small file per config + // fetch (once per page load) the obviously right trade. + const secretStorage = options.secretStorageResolver + ? await options.secretStorageResolver() + : options.initialConfig?.secretStorage; const payload = { ...options.initialConfig, writable, + ...(secretStorage ? { secretStorage } : {}), ...(options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}), }; return c.json(payload); @@ -1918,6 +1940,12 @@ export function createRemoteApp( ): Promise<{ migrated: MCPConfig; changed: boolean }> => { let changed = false; const next: MCPConfig = { mcpServers: {} }; + const durable = await secretStoreIsDurable(secretStore); + if (!durable && fileLogger) { + fileLogger.warn( + "Secrets are kept in memory for this session, so plaintext values in mcp.json are left on disk rather than migrated away. They would otherwise be lost when the Inspector exits.", + ); + } try { for (const [id, stored] of Object.entries(config.mcpServers)) { const { stripped, secrets } = extractSecretsFromStored(stored); @@ -1935,6 +1963,17 @@ export function createRemoteApp( // plaintext from disk. This handles the case where a user has // edited mcp.json by hand after the original migration. } + // Only strip the plaintext once it is somewhere that outlives us. + // Against a session-scoped store (the container fallback added in + // #1950) this would trade a secret that survives restarts for one + // that dies with the process — and it runs on an ordinary GET, so + // merely opening the app would destroy it. The values are still + // loaded into the store above, so this session behaves normally; + // only the disk delete is withheld. + if (!durable) { + next.mcpServers[id] = stored; + continue; + } next.mcpServers[id] = stripped; changed = true; } diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index 88c26c5c..91904305 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -98,6 +98,29 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { if (v.kind === "file") { if (typeof v.path !== "string" || v.path === "") return undefined; if (typeof v.plaintext !== "boolean") return undefined; + // Optional, so absent is fine — but a *present* value must be a real + // boolean. `pendingEncryption: "false"` is a truthy string, and the + // footer would read it as "already re-encrypting" and print advice that + // is the opposite of the truth. + if ( + v.pendingEncryption !== undefined && + typeof v.pendingEncryption !== "boolean" + ) { + return undefined; + } + if (v.looseMode !== undefined && typeof v.looseMode !== "number") { + return undefined; + } + } else if ( + // File-only fields on a keychain or memory descriptor mean the payload + // was not built by a backend we understand. Rejecting is cheap and the + // alternative is rendering a mixture of two stores' answers. + v.path !== undefined || + v.plaintext !== undefined || + v.pendingEncryption !== undefined || + v.looseMode !== undefined + ) { + return undefined; } return value as SecretStorageInfo; } From 6460d1804a6fd1d1900c8e552ec4e487763315e6 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 13:31:04 -0400 Subject: [PATCH 234/941] style(web): bold the "Secrets:" prefix in the settings-modal footers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weights the label so the band reads as a labelled fact rather than one run of prose — the eye lands on what the line is about before reading which store it names. Rendered as a `span` inside the existing `Text` rather than as a sibling in the `Group`: `Text` renders a `<p>`, so a nested one would be a `<p>` inside a `<p>` (invalid, and the browser closes the outer paragraph early), and a sibling would take the Group's `gap` and sit too far from the value it labels. `inherit` keeps it at the label's own size. Splitting the text is why the assertions move off `getByText`: it matches a string against an element's *direct* text nodes, so "Secrets: OS keychain" now reads as two fragments even though it renders as one line. The unit tests, both modal tests, and the four stories assert `toHaveTextContent` on the band instead, which reads the full subtree. Verified in the running dev server: prefix 700, store name 500, both at the same 12px. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.stories.tsx | 32 ++++++---------- .../SecretStorageFooter.test.tsx | 38 ++++++++----------- .../SecretStorageFooter.tsx | 21 +++++++++- .../ClientSettingsModal.test.tsx | 12 +++--- .../ServerSettingsModal.test.tsx | 8 ++-- 5 files changed, 58 insertions(+), 53 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx index 061b1212..6b0ce287 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx @@ -22,13 +22,11 @@ type Story = StoryObj<typeof SecretStorageFooter>; export const Keychain: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - await canvas.findByText("Secrets: OS keychain"), - ).toBeInTheDocument(); - await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "neutral", - ); + // Asserted on the band's full text: the "Secrets:" prefix is its own + // `<span>`, which a `findByText` string match would not see as one string. + const band = await canvas.findByTestId("secret-storage-footer"); + await expect(band).toHaveTextContent("Secrets: OS keychain"); + await expect(band).toHaveAttribute("data-tone", "neutral"); }, }; @@ -46,9 +44,8 @@ export const EncryptedFile: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - await canvas.findByText("Secrets: File (encrypted)"), - ).toBeInTheDocument(); + const band = await canvas.findByTestId("secret-storage-footer"); + await expect(band).toHaveTextContent("Secrets: File (encrypted)"); await expect( canvas.getByText("/home/node/.mcp-inspector/secrets.json"), ).toBeInTheDocument(); @@ -70,13 +67,9 @@ export const UnencryptedFile: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - await canvas.findByText("Secrets: File (unencrypted)"), - ).toBeInTheDocument(); - await expect(canvas.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "warn", - ); + const band = await canvas.findByTestId("secret-storage-footer"); + await expect(band).toHaveTextContent("Secrets: File (unencrypted)"); + await expect(band).toHaveAttribute("data-tone", "warn"); }, }; @@ -86,9 +79,8 @@ export const InMemory: Story = { args: { info: { kind: "memory", reason: "fallback", durable: false } }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect( - await canvas.findByText("Secrets: Memory (this session only)"), - ).toBeInTheDocument(); + const band = await canvas.findByTestId("secret-storage-footer"); + await expect(band).toHaveTextContent("Secrets: Memory (this session only)"); await expect( canvas.getByText(/lost when the Inspector exits/), ).toBeInTheDocument(); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index 90dca4a2..90548f38 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -25,25 +25,25 @@ const memory: SecretStorageInfo = { describe("SecretStorageFooter", () => { it("names the keychain with no caveat and no warning tone", () => { renderWithMantine(<SecretStorageFooter info={keyring} />); - expect(screen.getByText("Secrets: OS keychain")).toBeInTheDocument(); - expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "neutral", - ); + // Asserted on the band's full text rather than with `getByText`: the + // "Secrets:" prefix is its own `<span>`, and testing-library matches a + // string against an element's *direct* text nodes only, so the label now + // reads as two fragments even though it renders as one line. + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Secrets: OS keychain"); + expect(band).toHaveAttribute("data-tone", "neutral"); }); it("shows the path for an encrypted file, quietly", () => { // Nothing is wrong here, so the band must not shout — the path is the // useful part and the tone stays neutral. renderWithMantine(<SecretStorageFooter info={encryptedFile} />); - expect(screen.getByText("Secrets: File (encrypted)")).toBeInTheDocument(); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Secrets: File (encrypted)"); expect( screen.getByText("/home/node/.mcp-inspector/secrets.json"), ).toBeInTheDocument(); - expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "neutral", - ); + expect(band).toHaveAttribute("data-tone", "neutral"); }); it("renders the plaintext caveat inline, not only on hover", () => { @@ -51,28 +51,22 @@ describe("SecretStorageFooter", () => { // to hover to discover is not a warning. The caveat takes the visible // slot, displacing the path (which stays in the tooltip). renderWithMantine(<SecretStorageFooter info={plaintextFile} />); - expect(screen.getByText("Secrets: File (unencrypted)")).toBeInTheDocument(); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Secrets: File (unencrypted)"); expect( screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/), ).toBeInTheDocument(); - expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "warn", - ); + expect(band).toHaveAttribute("data-tone", "warn"); }); it("warns that an in-memory store loses secrets on exit", () => { renderWithMantine(<SecretStorageFooter info={memory} />); - expect( - screen.getByText("Secrets: Memory (this session only)"), - ).toBeInTheDocument(); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Secrets: Memory (this session only)"); expect( screen.getByText(/lost when the Inspector exits/), ).toBeInTheDocument(); - expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "warn", - ); + expect(band).toHaveAttribute("data-tone", "warn"); }); it("renders nothing when the backend didn't report a store", () => { diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 8bd445ba..802e530a 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -47,6 +47,22 @@ const FooterLabel = Text.withProps({ variant: "nowrap", }); +// "Secrets:" carries the heavier weight so the band reads as a labelled fact +// rather than as one run of prose — the eye lands on what the line is about +// before reading which store it names. +// +// A `span` inside the label rather than a sibling in the `Group`: `Text` +// renders a `<p>`, so a nested one would be a `<p>` inside a `<p>` (invalid, +// and the browser would close the outer paragraph early), and a sibling would +// take the Group's `gap` and sit too far from the value it labels. `inherit` +// keeps it at the label's own size and line height instead of Mantine's `Text` +// defaults. +const FooterLabelPrefix = Text.withProps({ + span: true, + inherit: true, + fw: 700, +}); + // The detail — a path, or the caveat sentence — is the part allowed to lose // characters, since the label before it carries the answer. // @@ -128,7 +144,10 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) { `.withProps()` rule, and it takes `color` as a prop rather than a style. */} <Icon size={ICON_SIZE} color={color} aria-hidden /> - <FooterLabel c={color}>Secrets: {secretStorageLabel(info)}</FooterLabel> + <FooterLabel c={color}> + <FooterLabelPrefix>Secrets:</FooterLabelPrefix>{" "} + {secretStorageLabel(info)} + </FooterLabel> {detail && <FooterDetail c={color}>{detail}</FooterDetail>} </FooterBand> ); diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx index 2e02df15..097165c5 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -492,13 +492,11 @@ describe("ClientSettingsModal", () => { }} />, ); - expect( - screen.getByText("Secrets: File (unencrypted)"), - ).toBeInTheDocument(); - expect(screen.getByTestId("secret-storage-footer")).toHaveAttribute( - "data-tone", - "warn", - ); + // Full-text assertion: the "Secrets:" prefix is its own `<span>`, which + // `getByText`'s direct-text-node matching would not see as one string. + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Secrets: File (unencrypted)"); + expect(band).toHaveAttribute("data-tone", "warn"); }); it("renders no footer when the backend didn't report a store", () => { diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 5479f22b..ba0303d4 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -920,9 +920,11 @@ describe("ServerSettingsModal", () => { }} />, ); - expect( - screen.getByText("Secrets: Memory (this session only)"), - ).toBeInTheDocument(); + // Full-text assertion: the "Secrets:" prefix is its own `<span>`, which + // `getByText`'s direct-text-node matching would not see as one string. + expect(screen.getByTestId("secret-storage-footer")).toHaveTextContent( + "Secrets: Memory (this session only)", + ); }); it("renders no footer when the backend didn't report a store", () => { From 050556e1a71dd970b45348c2243b56f427b2443c Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 13:40:32 -0400 Subject: [PATCH 235/941] fix(auth): address Copilot review round 4 on the secret-store PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings (two inline, two suppressed), all taken. Three are the lock, which is where the remaining sharp edges were. - **A lock could be created and then never stamped.** If `mkdir` succeeded but writing the owner token failed, the catch treated it as "cannot lock here", returned the no-op release, and left the directory behind. The mutation then ran unlocked *and* every later writer queued behind an orphan nobody could deliberately release, until it aged out. The acquisition now tracks whether it created the directory, removes it on a failed stamp, and refuses rather than entering the section unlocked. - **An old mtime was being treated as proof the owner was dead.** It is not: a holder that is SIGSTOPed, starved by a blocked event loop, or on a machine that suspended is alive and not running, and expiring it on elapsed time alone takes the lock out from under a live critical section — the lost update the lock exists to prevent, arrived at through the machinery meant to prevent it. The heartbeat narrowed the window; it could not close it, because the holder that most needs protecting is precisely the one not running its timers. The owner stamp now carries `pid` and `host`, and staleness only *opens* the question — on the same host a waiter asks the OS whether that pid still exists (`kill(pid, 0)`, which sends no signal; `EPERM` counts as alive, so a holder running as another user is never stolen from). Only a genuinely absent process is stolen from. Where the stamp cannot be trusted — another machine over a shared filesystem, or a missing/corrupt owner file — the mtime remains the answer, because the alternative is a lock nothing can ever break, turning one dead process into a permanent outage. - **Podman was classified as a host.** `/.dockerenv` is Docker's marker and rootless Podman has no equivalent of it, while its cgroup paths say `libpod-<id>.scope` rather than the literal "podman" the pattern looked for. Such a box fell back to a file in the ephemeral writable layer and reported it as durable — the wrong way round to be wrong. `/run/.containerenv` and `libpod` are both recognized now. - **Two tests floated a second `acquireLock`**, so the test could finish and `afterEach` could remove the temp directory while the acquisition was still running. Both hold and settle it now. Separately, a defect of my own found while re-reading round 3: `describeFileStore` called `tightenSecretFilePermissions`, so the function whose job is to *describe* the store issued a `chmod` on every `GET /api/config` — once per page load, on a file another process may be mid-write on. Split out a read-only `readSecretFilePermissions`; the repair stays at startup, where it happens once and its outcome is announced. Tests: a stale lock whose owner is alive is not stolen (and survives the attempt), one whose pid is gone is, one stamped by another host is; a failed ownership stamp reports and leaves nothing behind; Podman via both signals; and the read-only permission check leaves a 0644 file at 0644. Full `npm run ci` green — 5,711 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../auth/node/file-secret-store.test.ts | 159 +++++++++++++++++- .../auth/node/secret-store-selection.test.ts | 25 ++- core/auth/node/file-secret-store.ts | 143 ++++++++++++++-- core/auth/node/secret-store-selection.ts | 25 ++- 4 files changed, 324 insertions(+), 28 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 2d9d1bd6..12e2b97e 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -8,6 +8,7 @@ * agree with any of those without them being true. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync } from "node:fs"; import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; @@ -15,6 +16,7 @@ import * as path from "node:path"; import { acquireLock, FileSecretStore, + readSecretFilePermissions, SecretFileLockTimeoutError, SecretFileKeyMismatchError, tightenSecretFilePermissions, @@ -450,6 +452,21 @@ describe("tightenSecretFilePermissions", () => { expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600); }); + it("readSecretFilePermissions reports the mode without changing it", async () => { + // The descriptor is rebuilt on every GET /api/config, so the function it + // calls must not chmod — once per page load, on a file another process + // may be mid-write on. The repair belongs at startup. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + await fs.chmod(filePath(), 0o644); + expect(await readSecretFilePermissions(filePath())).toEqual({ + state: "loose", + mode: 0o644, + }); + // Still 0644 — reading did not quietly repair it. + expect((await fs.stat(filePath())).mode & 0o777).toBe(0o644); + }); + it("reports absent, not a failure, for a file that doesn't exist yet", async () => { // The first-run case. Nothing to tighten and nothing to warn about — // `writeStoreFile` creates the file 0600. @@ -684,16 +701,21 @@ describe("acquireLock", () => { // unlocked and overwrite each other. First writes are exactly when two // processes are most likely to start together. const target = path.join(tmpDir, "no-such-dir", "secrets.json"); - const release = await acquireLock(target); + const release = await acquireLock(target, { pollMs: 10 }); // A real lock was taken: a second acquisition must not be handed out. let secondTaken = false; - void acquireLock(target).then((r) => { + // Held, not floated: an acquisition still running when the test ends + // would race `afterEach` removing the temp directory. + const second = acquireLock(target, { pollMs: 10 }).then((r) => { secondTaken = true; - return r(); + return r; }); await new Promise((r) => setTimeout(r, 60)); expect(secondTaken).toBe(false); await release(); + await ( + await second + )(); }); it("releases only a lock it still owns", async () => { @@ -754,25 +776,148 @@ describe("acquireLock", () => { }); try { let stolen = false; - void acquireLock(target, { + // Held and settled below rather than floated, so the waiter cannot + // still be running when `afterEach` removes the temp directory. + const waiter = acquireLock(target, { staleMs: 120, timeoutMs: 400, pollMs: 10, }) - .then((r) => { + .then(async (r) => { stolen = true; - return r(); + await r(); }) .catch(() => { // Timed out, which is the expected outcome. }); - await new Promise((r) => setTimeout(r, 500)); + await waiter; expect(stolen).toBe(false); } finally { await release(); } }); + it("does not steal a stale lock whose owner process is still alive", async () => { + // An old mtime means the holder has not heartbeated lately, and there are + // two very different reasons: it died, or it is alive but not running — + // SIGSTOPed, event-loop-blocked, or on a machine that suspended. Taking + // the lock on elapsed time alone would pull it out from under a live + // critical section, which is the lost update the lock exists to prevent. + const target = path.join(tmpDir, "suspended.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + // Stamp it with *this* process, which is definitionally alive, and age it + // well past stale — the shape of a suspended holder. + await fs.writeFile( + path.join(lockDir, "owner"), + JSON.stringify({ + token: "someone", + pid: process.pid, + host: os.hostname(), + }), + "utf-8", + ); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + await expect( + acquireLock(target, { staleMs: 50, timeoutMs: 150, pollMs: 10 }), + ).rejects.toBeInstanceOf(SecretFileLockTimeoutError); + // Their lock survives the attempt. + expect(existsSync(lockDir)).toBe(true); + await fs.rm(lockDir, { recursive: true, force: true }); + }); + + it("steals a stale lock whose owner process is gone", async () => { + // The case the stealing exists for. A pid that no longer resolves is a + // dead holder, and refusing to break its lock would turn one killed + // container into permanently broken secret writes. + const target = path.join(tmpDir, "dead.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile( + path.join(lockDir, "owner"), + // A pid that cannot be running: `kill(0)` answers ESRCH. + JSON.stringify({ token: "gone", pid: 2 ** 31 - 1, host: os.hostname() }), + "utf-8", + ); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + const release = await acquireLock(target, { + staleMs: 50, + timeoutMs: 2_000, + pollMs: 10, + }); + await release(); + }); + + it("steals a stale lock stamped by another machine", async () => { + // A shared filesystem: we cannot ask this host about that host's pid, so + // the mtime is all there is. Refusing would make a lock nothing can ever + // break. + const target = path.join(tmpDir, "remote.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile( + path.join(lockDir, "owner"), + JSON.stringify({ + token: "elsewhere", + pid: process.pid, + host: "some-other-host", + }), + "utf-8", + ); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + const release = await acquireLock(target, { + staleMs: 50, + timeoutMs: 2_000, + pollMs: 10, + }); + await release(); + }); + + it("cleans up and reports when it cannot stamp ownership", async () => { + // mkdir succeeded, the owner write did not. Returning a no-op release + // here would enter the critical section unlocked *and* leave the + // directory behind, so every later writer queues behind an orphan nobody + // can deliberately release. + const target = path.join(tmpDir, "unstampable.json"); + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + return { + ...actual, + default: actual, + writeFile: vi.fn((p: string, ...rest: unknown[]) => + String(p).endsWith(`${path.sep}owner`) + ? Promise.reject(new Error("ENOSPC: no space left on device")) + : (actual.writeFile as (...a: unknown[]) => Promise<void>)( + p, + ...rest, + ), + ), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/file-secret-store.js"); + await expect(mod.acquireLock(target)).rejects.toThrow( + /Could not initialize the lock/, + ); + // And nothing is left obstructing the next writer. + expect(existsSync(`${target}.lock`)).toBe(false); + } finally { + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + }); + it("serializes concurrent writers through two independent store instances", async () => { // Two `FileSecretStore` objects have separate in-process queues, so this // is the cross-process race in miniature: without the file lock the second diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 0a5be897..d9d9616e 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -135,6 +135,7 @@ describe("isContainer", () => { */ async function loadWithFs(opts: { dockerenv?: boolean; + containerenv?: boolean; cgroup?: string | Error; }) { vi.resetModules(); @@ -142,8 +143,11 @@ describe("isContainer", () => { const actual = await vi.importActual<typeof import("node:fs")>("node:fs"); return { ...actual, - existsSync: (p: string) => - p === "/.dockerenv" ? !!opts.dockerenv : actual.existsSync(p), + existsSync: (p: string) => { + if (p === "/.dockerenv") return !!opts.dockerenv; + if (p === "/run/.containerenv") return !!opts.containerenv; + return actual.existsSync(p); + }, readFileSync: (p: string, enc?: unknown) => { if (p === "/proc/1/cgroup") { if (opts.cgroup instanceof Error) throw opts.cgroup; @@ -173,6 +177,23 @@ describe("isContainer", () => { expect(mod.isContainer()).toBe(true); }); + it("is true when Podman's /run/.containerenv exists", async () => { + // Rootless Podman has no `/.dockerenv`, and its cgroup line often does + // not contain the literal "podman" either — this file is the signal that + // is reliably there. Missing it classified the container as a host, so a + // keychain-less box wrote a "durable" file into the ephemeral layer. + const mod = await loadWithFs({ containerenv: true }); + expect(mod.isContainer()).toBe(true); + }); + + it("is true for a libpod cgroup path", async () => { + // What Podman actually writes: `/machine.slice/libpod-<id>.scope`. + const mod = await loadWithFs({ + cgroup: "0::/machine.slice/libpod-3f2a.scope\n", + }); + expect(mod.isContainer()).toBe(true); + }); + it("is true when a container runtime appears in /proc/1/cgroup", async () => { const mod = await loadWithFs({ cgroup: "0::/system.slice/containerd.service\n", diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 29589d36..1c0b169f 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -60,6 +60,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; +import * as os from "node:os"; import * as path from "node:path"; import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; import { @@ -300,6 +301,10 @@ export async function acquireLock( const lockDir = `${filePath}.lock`; const ownerFile = `${lockDir}/owner`; const token = crypto.randomBytes(16).toString("hex"); + // The stamp a would-be thief consults before taking this lock: the token + // proves ownership on release, the pid and host let a waiter ask the OS + // whether we are still alive rather than inferring it from elapsed time. + const owner: LockOwner = { token, pid: process.pid, host: os.hostname() }; const noop = async (): Promise<void> => {}; const deadline = Date.now() + timeoutMs; @@ -317,9 +322,16 @@ export async function acquireLock( } for (;;) { + let created = false; try { await fs.mkdir(lockDir, { recursive: false }); - await fs.writeFile(ownerFile, token, "utf-8"); + created = true; + // Stamp ownership before entering the section. If this fails the lock + // exists but says nothing about who holds it, so nobody could ever + // release it deliberately and every later writer would queue behind an + // orphan until it aged out. Clean up and report, rather than proceeding + // unlocked *and* leaving the obstruction behind. + await fs.writeFile(ownerFile, JSON.stringify(owner), "utf-8"); // Prove liveness for as long as we hold it. `unref` so a held lock // never keeps the process alive on its own — the interval is a // side-channel, not work anyone is waiting for. @@ -336,8 +348,8 @@ export async function acquireLock( // Release only what we still own. A lock judged stale and stolen // now belongs to someone else, and removing it would drop them // into a race with whoever comes next. - const held = await fs.readFile(ownerFile, "utf-8"); - if (held !== token) return; + const held = parseOwner(await fs.readFile(ownerFile, "utf-8")); + if (held?.token !== token) return; await fs.rm(lockDir, { recursive: true, force: true }); } catch { // Already gone, or unreadable. Either way there is nothing to @@ -346,15 +358,17 @@ export async function acquireLock( }; } catch (err) { const code = (err as NodeJS.ErrnoException).code; + if (created) { + // We made the directory and then failed to stamp it. Ours to remove. + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {}); + throw new SecretStoreUnavailableError( + `Could not initialize the lock for the secrets file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } if (code !== "EEXIST") return noop; // cannot lock here at all — see above - try { - const held = (await fs.stat(lockDir)).mtimeMs; - if (Date.now() - held > staleMs) { - await fs.rm(lockDir, { recursive: true, force: true }); - continue; - } - } catch { - // The holder released it between our mkdir and our stat. Retry. + if (await canSteal(lockDir, ownerFile, staleMs)) { + await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {}); + continue; } if (Date.now() >= deadline) throw new SecretFileLockTimeoutError(filePath); @@ -363,6 +377,89 @@ export async function acquireLock( } } +/** + * May this waiter take a lock it found already held? + * + * Elapsed wall time alone does not answer that. An old mtime means the + * holder has not run its heartbeat lately, and there are two very different + * reasons for that: it died, or it is *alive but not running* — SIGSTOPed, + * starved by a blocked event loop, or on a machine that suspended. Expiring + * on time alone treats those identically and takes the lock out from under a + * live critical section, which is the lost update the lock exists to + * prevent, now arrived at through the machinery meant to prevent it. + * + * So the mtime only opens the question, and the owner stamp answers it: on + * the same host, ask the operating system whether that pid is still there. + * `kill(pid, 0)` sends no signal — it is the standard existence probe. + * `EPERM` counts as alive (the process exists; it just isn't ours), so a + * holder running as another user is never stolen from. + * + * The stamp is not always usable — a lock written by a different machine + * over a shared filesystem, or one whose owner file is missing or corrupt. + * There the mtime is all there is, and stealing on it is still right: the + * alternative is a lock nothing can ever break, which turns one dead process + * into a permanent outage. + */ +async function canSteal( + lockDir: string, + ownerFile: string, + staleMs: number, +): Promise<boolean> { + let held: number; + try { + held = (await fs.stat(lockDir)).mtimeMs; + } catch { + // Released between our mkdir and our stat — retry rather than steal. + return false; + } + if (Date.now() - held <= staleMs) return false; + + let owner: LockOwner | null = null; + try { + owner = parseOwner(await fs.readFile(ownerFile, "utf-8")); + } catch { + // No stamp to consult; fall through to the time-only answer. + } + if (!owner || owner.host !== os.hostname() || typeof owner.pid !== "number") { + return true; + } + return !processIsAlive(owner.pid); +} + +/** Does this pid exist? `kill(pid, 0)` is the probe; it sends no signal. */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + // EPERM means it exists and belongs to someone else — still alive. + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +interface LockOwner { + token: string; + pid: number; + host: string; +} + +/** Parse an owner stamp, tolerating anything that isn't one. */ +function parseOwner(raw: string): LockOwner | null { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return null; + const { token, pid, host } = parsed as Partial<LockOwner>; + if (typeof token !== "string") return null; + return { + token, + pid: typeof pid === "number" ? pid : -1, + host: String(host), + }; + } catch { + return null; + } +} + export interface FileSecretStoreOptions { /** Absolute path of the secrets file. */ filePath: string; @@ -696,9 +793,24 @@ export async function tightenSecretFilePermissions( } // Verify rather than assume. The chmod above is best-effort, but the UI and // the README both state the file is 0600 — so a file we failed to tighten - // (owned by another user, on a read-only mount) must be *reported*, not - // quietly described as protected. This is the one place that can tell the - // difference, because it is the only one that looks at the file. + // (owned by another user, on a read-only mount) must be reported, not + // quietly described as protected. + return readSecretFilePermissions(filePath); +} + +/** + * Read the file's permission state **without touching it**. + * + * The read-only half of {@link tightenSecretFilePermissions}, split out + * because the descriptor is rebuilt on every `GET /api/config` and a + * function whose job is to *describe* must not chmod as a side effect — + * once per page load, on a file another process may be mid-write on. The + * repair belongs at startup, where it happens once and its outcome is + * announced; from then on the question is only "what is the mode now". + */ +export async function readSecretFilePermissions( + filePath: string, +): Promise<SecretFilePermissionState> { try { const mode = (await fs.stat(filePath)).mode & 0o777; if (mode === 0o600) return { state: "ok" }; @@ -711,7 +823,8 @@ export async function tightenSecretFilePermissions( } /** - * Outcome of {@link tightenSecretFilePermissions}: the file is `0600` + * Outcome of {@link tightenSecretFilePermissions} and + * {@link readSecretFilePermissions}: the file is `0600` * (`ok`), does not exist yet (`absent`), or exists with a wider mode we * could not fix (`loose`) — the only case anyone needs to hear about. */ diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index 637b7148..c4fba79d 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -49,6 +49,7 @@ import { } from "../secret-storage-info.js"; import { FileSecretStore, + readSecretFilePermissions, tightenSecretFilePermissions, } from "./file-secret-store.js"; import { @@ -132,13 +133,26 @@ export function defaultSecretFilePath(): string { export function isContainer(): boolean { if (process.env.KUBERNETES_SERVICE_HOST) return true; try { - if (fsSync.existsSync("/.dockerenv")) return true; + // `/.dockerenv` is Docker's marker; `/run/.containerenv` is Podman's + // equivalent and is the only one a rootless Podman container reliably + // has. Missing it classified such a container as a host, which is the + // wrong way round to be wrong: the fallback then writes a file into the + // ephemeral writable layer and calls it durable. + if ( + fsSync.existsSync("/.dockerenv") || + fsSync.existsSync("/run/.containerenv") + ) { + return true; + } } catch { // A sandboxed / restricted filesystem. Fall through to the cgroup read. } try { const cgroup = fsSync.readFileSync("/proc/1/cgroup", "utf-8"); - return /\b(docker|containerd|kubepods|podman|lxc)\b/.test(cgroup); + // `libpod` is what Podman actually writes into cgroup paths + // (`/machine.slice/libpod-<id>.scope`); the literal string "podman" + // often does not appear at all. + return /\b(docker|containerd|kubepods|podman|libpod|lxc)\b/.test(cgroup); } catch { // Not Linux, or /proc is not mounted. Not a container as far as we know. return false; @@ -300,8 +314,11 @@ async function describeFileStore( const plaintext = onDisk === null ? !store.encrypted : onDisk === "none"; // Re-checked here rather than captured at selection, for the same reason // the encryption state is: the mode can change under a running process, - // and this descriptor is what the browser renders as fact. - const perms = await tightenSecretFilePermissions(filePath); + // and this descriptor is what the browser renders as fact. The *read-only* + // half deliberately — describing must not chmod, least of all once per + // page load on a file another process may be mid-write on. The repair + // happens once, at selection. + const perms = await readSecretFilePermissions(filePath); return { kind, reason, From 0cb59cf3fdb8e9c81eabbf08d160f1d073c07a24 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 13:50:40 -0400 Subject: [PATCH 236/941] fix(auth): stop heartbeating a lock that is no longer ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on a re-read of the round-4 liveness work, not by review. The heartbeat refreshed the lock directory's mtime unconditionally. After a steal — possible when this process looked dead, or across hosts where the pid check cannot apply — the directory belongs to someone else, and touching it keeps *their* lock looking alive. If they then die, the stale timestamp nobody is maintaining is being maintained by us, and a third writer can never break in: a stale lock turned unbreakable, which is the permanent outage the stealing exists to avoid. The heartbeat now re-reads the owner stamp and clears itself when the token no longer matches, so it only ever refreshes a lock we still hold. The release path already checked the token; this closes the same hole on the other side. Tested by stamping a different owner onto a held lock and aging it: the mtime stays old across several heartbeat intervals, and release leaves the thief's lock intact. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../auth/node/file-secret-store.test.ts | 26 +++++++++++++++++++ core/auth/node/file-secret-store.ts | 23 +++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 12e2b97e..bd2c9820 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -879,6 +879,32 @@ describe("acquireLock", () => { await release(); }); + it("stops heartbeating a lock that is no longer ours", async () => { + // After a steal, the directory belongs to someone else. Continuing to + // touch it would keep *their* lock looking alive after they died, which + // turns a stale lock into an unbreakable one. + const target = path.join(tmpDir, "stolen.json"); + const lockDir = `${target}.lock`; + const release = await acquireLock(target, { heartbeatMs: 10 }); + // Someone judges us dead and takes it. + await fs.writeFile( + path.join(lockDir, "owner"), + JSON.stringify({ token: "thief", pid: process.pid, host: os.hostname() }), + "utf-8", + ); + const afterSteal = new Date(Date.now() - 30_000); + await fs.utimes(lockDir, afterSteal, afterSteal); + // Give the heartbeat several chances to (wrongly) refresh it. + await new Promise((r) => setTimeout(r, 80)); + const mtime = (await fs.stat(lockDir)).mtimeMs; + expect(Date.now() - mtime).toBeGreaterThan(1_000); + // And our release leaves their lock alone, since the token no longer + // matches. + await release(); + expect(existsSync(lockDir)).toBe(true); + await fs.rm(lockDir, { recursive: true, force: true }); + }); + it("cleans up and reports when it cannot stamp ownership", async () => { // mkdir succeeded, the owner write did not. Returning a no-op release // here would enter the critical section unlocked *and* leave the diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 1c0b169f..9b954a91 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -336,10 +336,25 @@ export async function acquireLock( // never keeps the process alive on its own — the interval is a // side-channel, not work anyone is waiting for. const heartbeat = setInterval(() => { - const now = new Date(); - void fs.utimes(lockDir, now, now).catch(() => { - // Lock already released or stolen; the release below copes. - }); + // Confirm the lock is still ours before refreshing it. If it was + // stolen (only possible when we looked dead, or across hosts where + // the pid check cannot apply), the directory now belongs to someone + // else — and touching it would keep *their* lock looking alive after + // they died, which is how a stale lock becomes an unbreakable one. + void (async () => { + try { + const held = parseOwner(await fs.readFile(ownerFile, "utf-8")); + if (held?.token !== token) { + clearInterval(heartbeat); + return; + } + const now = new Date(); + await fs.utimes(lockDir, now, now); + } catch { + // Released, stolen, or unreadable. The release below copes, and + // there is nothing useful to do from a timer. + } + })(); }, heartbeatMs); heartbeat.unref?.(); return async () => { From 2d2c787e22cd7fc31c65be535ea1a0b1d9c4438a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 13:56:13 -0400 Subject: [PATCH 237/941] style(web): tighten the in-memory caveat and punctuate the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads "Memory (this session only): Secrets are not written anywhere and are lost on exit." — shorter tail than "lost when the Inspector exits", and a colon separating the store from what follows it. The colon is added at the footer's composition, not inside `secretStorageLabel`. The label is also consumed alone by `secretStorageSummary`, which appends " at <path>" for a file-backed store, so a colon baked into the label would print "File (encrypted): at /path" in the startup banner. It renders only when a detail actually follows. The caveat string is shared with the banner's warning line, so the terminal picks up the shorter wording too. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.stories.tsx | 8 +- .../SecretStorageFooter.test.tsx | 6 +- .../SecretStorageFooter.tsx | 5 + .../core/auth/secret-storage-info.test.ts | 4 +- .../auth/node/secret-store-selection.test.ts | 108 +++++++++++++++++- core/auth/secret-storage-info.ts | 6 +- 6 files changed, 123 insertions(+), 14 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx index 6b0ce287..38d7a245 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx @@ -80,10 +80,10 @@ export const InMemory: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const band = await canvas.findByTestId("secret-storage-footer"); - await expect(band).toHaveTextContent("Secrets: Memory (this session only)"); - await expect( - canvas.getByText(/lost when the Inspector exits/), - ).toBeInTheDocument(); + await expect(band).toHaveTextContent( + "Secrets: Memory (this session only):", + ); + await expect(canvas.getByText(/lost on exit/)).toBeInTheDocument(); }, }; diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index 90548f38..a18a7d0b 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -62,10 +62,8 @@ describe("SecretStorageFooter", () => { it("warns that an in-memory store loses secrets on exit", () => { renderWithMantine(<SecretStorageFooter info={memory} />); const band = screen.getByTestId("secret-storage-footer"); - expect(band).toHaveTextContent("Secrets: Memory (this session only)"); - expect( - screen.getByText(/lost when the Inspector exits/), - ).toBeInTheDocument(); + expect(band).toHaveTextContent("Secrets: Memory (this session only):"); + expect(screen.getByText(/lost on exit/)).toBeInTheDocument(); expect(band).toHaveAttribute("data-tone", "warn"); }); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 802e530a..74e81039 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -144,9 +144,14 @@ export function SecretStorageFooter({ info }: SecretStorageFooterProps) { `.withProps()` rule, and it takes `color` as a prop rather than a style. */} <Icon size={ICON_SIZE} color={color} aria-hidden /> + {/* The colon lives here rather than in `secretStorageLabel`, because the + label is also used on its own — by `secretStorageSummary` for the + startup banner, where a trailing colon before " at <path>" would be + wrong. It appears only when something follows it. */} <FooterLabel c={color}> <FooterLabelPrefix>Secrets:</FooterLabelPrefix>{" "} {secretStorageLabel(info)} + {detail ? ":" : ""} </FooterLabel> {detail && <FooterDetail c={color}>{detail}</FooterDetail>} </FooterBand> diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index c21afe43..5bfcd48b 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -77,7 +77,7 @@ describe("secretStorageCaveat", () => { it("names the loss for memory", () => { expect(secretStorageCaveat(memory)).toContain( - "lost when the Inspector exits", + "lost on exit", ); }); @@ -127,7 +127,7 @@ describe("secretStorageCaveat", () => { // Defensive ordering: `plaintext` is meaningless for memory, and the // durability loss is the more consequential of the two statements. expect(secretStorageCaveat({ ...memory, plaintext: true })).toContain( - "lost when the Inspector exits", + "lost on exit", ); }); }); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index d9d9616e..9670ac44 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -18,6 +18,7 @@ * which is what makes them worth exporting. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -27,6 +28,10 @@ import { parseSecretStoreEnv, warnAboutSecretStorage, } from "@inspector/core/auth/node/secret-store-selection.js"; +import { + InMemorySecretStore, + type SecretStore, +} from "@inspector/core/auth/node/secret-store.js"; import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; const ENV_KEYS = [ @@ -384,7 +389,7 @@ describe("warnAboutSecretStorage", () => { expect(output).toContain("MCP_INSPECTOR_SECRET_STORE"); // The lossy-store caveat rides along — this is the "loud" half of // "automatic fallback with a loud banner". - expect(output).toContain("lost when the Inspector exits"); + expect(output).toContain("lost on exit"); }); it("warns about an unencrypted file even when it was explicitly chosen", () => { @@ -540,6 +545,107 @@ describe("defaultSecretFilePath", () => { }); }); +describe("absorbFileSecretsIntoKeyring", () => { + // The transition: a box without libsecret falls back to a file, the user + // saves secrets there, then installs libsecret. Without a hand-off the next + // run selects the keychain and every saved value silently disappears — + // still on disk, but read by nothing. + async function seedFile(secrets: Record<string, string>): Promise<string> { + const filePath = path.join(tmpDir, "secrets.json"); + await fs.writeFile( + filePath, + JSON.stringify({ version: 1, encryption: "none", secrets }), + "utf-8", + ); + return filePath; + } + + it("moves the file's secrets into the keychain and removes the file", async () => { + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const keyring = new InMemorySecretStore(); + + await mod.absorbFileSecretsIntoKeyring(keyring); + + expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file"); + expect(existsSync(filePath)).toBe(false); + }); + + it("lets the keychain win on conflict", async () => { + // The file's copy is the older one by construction — it was written while + // the keychain was unreachable. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const keyring = new InMemorySecretStore(); + await keyring.set("srv", "oauthClientSecret", "already-here"); + + await mod.absorbFileSecretsIntoKeyring(keyring); + + expect(await keyring.get("srv", "oauthClientSecret")).toBe("already-here"); + }); + + it("leaves the file alone when the keychain write fails", async () => { + // A partial hand-off must not delete the source: the next run retries and + // the keychain-wins rule absorbs whatever already made it across. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + const failing: SecretStore = { + get: async () => null, + set: async () => { + throw new Error("keychain exploded"); + }, + delete: async () => {}, + deleteAllForServer: async () => {}, + }; + + await mod.absorbFileSecretsIntoKeyring(failing); + + expect(existsSync(filePath)).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("left in place")); + }); + + it("does not delete a file it cannot read", async () => { + // An undecryptable file is not an empty one. Deleting it here would lose + // exactly the values this whole hand-off exists to preserve. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_KEY = "right-key"; + const seeded = await loadWithProbe(false); + vi.spyOn(console, "warn").mockImplementation(() => {}); + await new ( + await import("@inspector/core/auth/node/file-secret-store.js") + ).FileSecretStore({ filePath, passphrase: "right-key" }).set( + "srv", + "oauthClientSecret", + "encrypted", + ); + void seeded; + + delete process.env.MCP_INSPECTOR_SECRET_KEY; + const mod = await loadWithProbe(true); + const keyring = new InMemorySecretStore(); + + await mod.absorbFileSecretsIntoKeyring(keyring); + + expect(existsSync(filePath)).toBe(true); + expect(await keyring.get("srv", "oauthClientSecret")).toBe(null); + }); + + it("is a no-op when there is no file", async () => { + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "absent.json"); + const mod = await loadWithProbe(true); + const keyring = new InMemorySecretStore(); + await mod.absorbFileSecretsIntoKeyring(keyring); + expect(existsSync(path.join(tmpDir, "absent.json"))).toBe(false); + }); +}); + describe("the descriptor reports a mode it could not tighten", () => { it("carries looseMode into the descriptor and warns once at selection", async () => { // The warning alone was not enough: the caveat under the secret field diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index d1b42ab6..5435222c 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -121,14 +121,14 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone { /** * One sentence of consequence, or undefined when there is none worth - * saying. Consequence, not mechanism: "will be lost when the Inspector - * exits" is what a user can act on; "uses an in-memory Map" is not. + * saying. Consequence, not mechanism: "lost on exit" is what a user can + * act on; "uses an in-memory Map" is not. */ export function secretStorageCaveat( info: SecretStorageInfo, ): string | undefined { if (info.kind === "memory") { - return "Secrets are not written anywhere and are lost when the Inspector exits."; + return "Secrets are not written anywhere and are lost on exit."; } // The permission problem outranks the encryption one when both hold: a // file other users can read is a live exposure, while "unencrypted" is a From 71cb7f3eae5cfc305cf406f2e82805b13a4c4758 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 14:05:13 -0400 Subject: [PATCH 238/941] feat(web): state the file store as a sentence, and copy its path on click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer's file states now read "Secrets: Plaintext file. Owner-only permissions." and "Secrets: Encrypted file. Owner-only permissions.", and the band itself became the copy affordance for the path. Three things worth reviewing: - The path is no longer printed on the band. It goes to the clipboard instead, which means the only place it stays *readable* is the button's accessible name — so that carries it in full ("Copy secrets file path: /home/node/.mcp-inspector/secrets.json") rather than a generic "Copy". A clipboard is no use to a screen-reader user who cannot inspect it. - "Owner-only permissions." is a claim of fact, so it is made only when the mode was verified as 0600. `looseMode` is set exactly when it is something else and could not be tightened, and the message says "Mode 0644 — not owner-only." there. A footer that asserted the opposite of the truth about a secrets file is the one failure this band exists to prevent, so it has its own test. - The footer phrases its own copy rather than reusing `secretStorageLabel` for the file case: that label is parenthetical ("File (unencrypted)") because the startup banner appends " at <path>" to it. The tone and the underlying facts still come from the shared helpers, so the two surfaces cannot disagree about anything that matters. Memory's line is still derived from the shared caveat. The click target is an `UnstyledButton` filling the band edge to edge — a real control, reachable by keyboard and announced as a button, rather than a click handler on a div. Padding moved from the band to the inner row so the button has no dead margins. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.stories.tsx | 14 +- .../SecretStorageFooter.test.tsx | 71 +++++-- .../SecretStorageFooter.tsx | 194 +++++++++++++----- .../ClientSettingsModal.test.tsx | 4 +- .../core/auth/secret-storage-info.test.ts | 4 +- 5 files changed, 216 insertions(+), 71 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx index 38d7a245..2962d601 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.stories.tsx @@ -45,9 +45,15 @@ export const EncryptedFile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const band = await canvas.findByTestId("secret-storage-footer"); - await expect(band).toHaveTextContent("Secrets: File (encrypted)"); + await expect(band).toHaveTextContent( + "Secrets: Encrypted file. Owner-only permissions.", + ); + // The path is no longer printed on the band — it goes to the clipboard, + // and stays readable in the button's accessible name. await expect( - canvas.getByText("/home/node/.mcp-inspector/secrets.json"), + canvas.getByRole("button", { + name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json", + }), ).toBeInTheDocument(); }, }; @@ -68,7 +74,9 @@ export const UnencryptedFile: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const band = await canvas.findByTestId("secret-storage-footer"); - await expect(band).toHaveTextContent("Secrets: File (unencrypted)"); + await expect(band).toHaveTextContent( + "Secrets: Plaintext file. Owner-only permissions.", + ); await expect(band).toHaveAttribute("data-tone", "warn"); }, }; diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index a18a7d0b..d8b836da 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -1,4 +1,5 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import type { SecretStorageInfo } from "@inspector/core/auth/secret-storage-info.js"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; import { SecretStorageFooter } from "./SecretStorageFooter"; @@ -34,31 +35,69 @@ describe("SecretStorageFooter", () => { expect(band).toHaveAttribute("data-tone", "neutral"); }); - it("shows the path for an encrypted file, quietly", () => { - // Nothing is wrong here, so the band must not shout — the path is the - // useful part and the tone stays neutral. + it("states an encrypted file quietly, and offers its path for copying", () => { + // Nothing is wrong here, so the band must not shout. The path is no longer + // printed on the band — it goes to the clipboard — so the accessible name + // is where it stays readable. renderWithMantine(<SecretStorageFooter info={encryptedFile} />); const band = screen.getByTestId("secret-storage-footer"); - expect(band).toHaveTextContent("Secrets: File (encrypted)"); - expect( - screen.getByText("/home/node/.mcp-inspector/secrets.json"), - ).toBeInTheDocument(); + expect(band).toHaveTextContent( + "Secrets: Encrypted file. Owner-only permissions.", + ); expect(band).toHaveAttribute("data-tone", "neutral"); + expect( + screen.getByRole("button", { + name: "Copy secrets file path: /home/node/.mcp-inspector/secrets.json", + }), + ).toBeInTheDocument(); }); - it("renders the plaintext caveat inline, not only on hover", () => { - // This is the "loud banner" decision made concrete: a warning you have - // to hover to discover is not a warning. The caveat takes the visible - // slot, displacing the path (which stays in the tooltip). + it("says a plaintext file is plaintext, in the warning tone", () => { + // The "loud warning" decision made concrete: the state is on the band + // itself, not hidden behind a hover. renderWithMantine(<SecretStorageFooter info={plaintextFile} />); const band = screen.getByTestId("secret-storage-footer"); - expect(band).toHaveTextContent("Secrets: File (unencrypted)"); - expect( - screen.getByText(/Set MCP_INSPECTOR_SECRET_KEY to encrypt them/), - ).toBeInTheDocument(); + expect(band).toHaveTextContent( + "Secrets: Plaintext file. Owner-only permissions.", + ); expect(band).toHaveAttribute("data-tone", "warn"); }); + it("copies the path on click", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + // happy-dom has no clipboard; define one so the copy path is exercised + // rather than merely not throwing. + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + renderWithMantine(<SecretStorageFooter info={plaintextFile} />); + await user.click(screen.getByRole("button", { name: /Copy secrets file/ })); + expect(writeText).toHaveBeenCalledWith( + "/home/node/.mcp-inspector/secrets.json", + ); + }); + + it("is inert for a store with no file to point at", () => { + // A keychain or in-memory store has no path, so there is nothing to copy + // and the band must not pretend to be a control. + renderWithMantine(<SecretStorageFooter info={keyring} />); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("declines to claim owner-only permissions when the mode says otherwise", () => { + // The one assertion the band must never get wrong: `looseMode` is set + // exactly when 0600 could not be reached, so "Owner-only permissions." + // there would state the opposite of the truth. + renderWithMantine( + <SecretStorageFooter info={{ ...plaintextFile, looseMode: 0o644 }} />, + ); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Mode 0644 — not owner-only."); + expect(band).not.toHaveTextContent("Owner-only permissions."); + }); + it("warns that an in-memory store loses secrets on exit", () => { renderWithMantine(<SecretStorageFooter info={memory} />); const band = screen.getByTestId("secret-storage-footer"); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 74e81039..29645107 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -1,4 +1,11 @@ -import { Group, Text, Tooltip } from "@mantine/core"; +import type { ReactNode } from "react"; +import { + CopyButton, + Group, + Text, + Tooltip, + UnstyledButton, +} from "@mantine/core"; import { TbAlertTriangle, TbDeviceDesktop, TbLock } from "react-icons/tb"; import { secretStorageCaveat, @@ -34,17 +41,50 @@ export interface SecretStorageFooterProps { const FooterBand = Group.withProps({ variant: "stickyModalFooter", h: SECRET_STORAGE_FOOTER_HEIGHT, - px: "md", - gap: 6, + gap: 0, align: "center", wrap: "nowrap", w: "100%", }); -const FooterLabel = Text.withProps({ +// The icon + message row. Carries the horizontal padding (rather than the band) +// so that when the row is a copy button it covers the band edge to edge — a +// click target with dead margins is a click target people miss. +const FooterRow = Group.withProps({ + h: "100%", + w: "100%", + px: "md", + gap: 6, + align: "center", + wrap: "nowrap", +}); + +// The whole strip becomes the copy affordance for a file-backed store. An +// `UnstyledButton` rather than a click handler on the Group: this is a real +// action, so it needs to be reachable by keyboard and announced as a button. +const CopyTarget = UnstyledButton.withProps({ + h: "100%", + w: "100%", +}); + +// The whole statement, prefix included, as one clamped line. +// +// `lineClamp` rather than `truncate`, and the difference is load-bearing. +// `Modal.Content` sizes its scroll container to its children's **max-content** +// width, and `truncate` sets `white-space: nowrap`, whose max-content is the +// entire sentence — so the band grew the container to 770px inside a 620px +// modal and the *form above it* got clipped at the right edge (measured). +// Clamped text still wraps internally, so its max-content is merely its +// longest word, and the container stays the modal's width; the clamp then +// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1` +// let it shrink to the leftover space rather than to its content. +const FooterMessage = Text.withProps({ size: "xs", fw: 500, - variant: "nowrap", + lineClamp: 1, + miw: 0, + flex: 1, + ta: "left", }); // "Secrets:" carries the heavier weight so the band reads as a labelled fact @@ -63,25 +103,6 @@ const FooterLabelPrefix = Text.withProps({ fw: 700, }); -// The detail — a path, or the caveat sentence — is the part allowed to lose -// characters, since the label before it carries the answer. -// -// `lineClamp` rather than `truncate`, and the difference is load-bearing. -// `Modal.Content` sizes its scroll container to its children's **max-content** -// width, and `truncate` sets `white-space: nowrap`, whose max-content is the -// entire sentence — so the band grew the container to 770px inside a 620px -// modal and the *form above it* got clipped at the right edge (measured). -// Clamped text still wraps internally, so its max-content is merely its -// longest word, and the container stays the modal's width; the clamp then -// keeps it to the one line this 32px band has room for. `miw: 0` + `flex: 1` -// let it shrink to the leftover space rather than to its content. -const FooterDetail = Text.withProps({ - size: "xs", - lineClamp: 1, - miw: 0, - flex: 1, -}); - // `multiline` + a fixed width so the sentence wraps instead of forming a // tooltip wider than the modal it explains. Only the `label` is dynamic. const CaveatTooltip = Tooltip.withProps({ @@ -93,6 +114,70 @@ const CaveatTooltip = Tooltip.withProps({ const ICON_SIZE = 14; +/** Octal mode, as `stat` would print it (`0644`). */ +const formatMode = (mode: number): string => mode.toString(8).padStart(4, "0"); + +/** + * The visible statement, per store. + * + * Deliberately not built from `secretStorageLabel` for the file case: that + * label is parenthetical ("File (unencrypted)") because the startup banner + * appends " at <path>" to it, and the footer wants a sentence instead. The + * two surfaces have different shapes, so each phrases its own; the *tone* + * and the underlying facts still come from the shared helpers, which is + * what keeps them from disagreeing about anything that matters. + */ +function footerMessage(info: SecretStorageInfo): string { + if (info.kind === "keyring") return secretStorageLabel(info); + if (info.kind === "memory") { + // Derived rather than restated — memory's consequence is the caveat, and + // the banner prints the same sentence. + const caveat = secretStorageCaveat(info); + return caveat + ? `${secretStorageLabel(info)}: ${caveat}` + : secretStorageLabel(info); + } + const encryption = info.plaintext ? "Plaintext" : "Encrypted"; + // "Owner-only permissions." is a claim of fact, so it is only made when the + // mode was actually verified as 0600. `looseMode` is set precisely when it + // is something else and could not be tightened, and saying "owner-only" + // there would be the footer asserting the opposite of what is true — the + // one failure this whole band exists to prevent. + const permissions = + info.looseMode === undefined + ? "Owner-only permissions." + : `Mode ${formatMode(info.looseMode)} — not owner-only.`; + return `${encryption} file. ${permissions}`; +} + +/** + * The hover text, per store. Undefined leaves the band without a tooltip. + * + * For a file-backed store it always ends with the copy hint, because the + * path is no longer shown on the band — it is in the clipboard instead, and + * an affordance nobody knows about is not one. + */ +function footerTooltip(info: SecretStorageInfo): string | undefined { + if (info.kind !== "file" || !info.path) return undefined; + const parts: string[] = []; + if (info.plaintext) { + // Advice that can actually clear the condition: someone who has already + // set the passphrase is waiting on the next write, not on themselves. + parts.push( + info.pendingEncryption + ? "Re-encrypted the next time a secret is saved." + : "Set MCP_INSPECTOR_SECRET_KEY to encrypt.", + ); + } + if (info.looseMode !== undefined) { + parts.push( + `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, + ); + } + parts.push("Click to copy file path"); + return parts.join(" "); +} + /** * The permanent "here is where your secrets go" strip at the bottom of the * Client Settings and Server Settings modals (#1950). @@ -121,46 +206,59 @@ const ICON_SIZE = 14; export function SecretStorageFooter({ info }: SecretStorageFooterProps) { if (!info) return null; - const caveat = secretStorageCaveat(info); const warn = secretStorageTone(info) === "warn"; const color = warn ? "var(--inspector-warning-text)" : "var(--inspector-text-secondary)"; - // The caveat wins the visible slot when there is one; otherwise the path, - // which only the (quiet) encrypted-file case has. - const detail = caveat ?? info.path; const Icon = warn ? TbAlertTriangle : info.kind === "keyring" ? TbDeviceDesktop : TbLock; - const band = ( - <FooterBand - data-testid="secret-storage-footer" - data-tone={warn ? "warn" : "neutral"} - > + const row = ( + <FooterRow> {/* A react-icons glyph, not a Mantine factory component — outside the `.withProps()` rule, and it takes `color` as a prop rather than a style. */} <Icon size={ICON_SIZE} color={color} aria-hidden /> - {/* The colon lives here rather than in `secretStorageLabel`, because the - label is also used on its own — by `secretStorageSummary` for the - startup banner, where a trailing colon before " at <path>" would be - wrong. It appears only when something follows it. */} - <FooterLabel c={color}> - <FooterLabelPrefix>Secrets:</FooterLabelPrefix>{" "} - {secretStorageLabel(info)} - {detail ? ":" : ""} - </FooterLabel> - {detail && <FooterDetail c={color}>{detail}</FooterDetail>} + <FooterMessage c={color}> + <FooterLabelPrefix>Secrets:</FooterLabelPrefix> {footerMessage(info)} + </FooterMessage> + </FooterRow> + ); + + const tip = footerTooltip(info); + const band = (content: ReactNode) => ( + <FooterBand + data-testid="secret-storage-footer" + data-tone={warn ? "warn" : "neutral"} + > + {content} </FooterBand> ); - // Tooltip content: the caveat plus the path when both exist (the unencrypted - // file case), since the visible slot can only show one of them. - const tip = [caveat, info.path].filter(Boolean).join(" "); - if (!tip) return band; + // A keychain or in-memory store has no path, so there is nothing to copy and + // the band stays inert — no button, no tooltip beyond what it already shows. + if (!info.path) return band(row); - return <CaveatTooltip label={tip}>{band}</CaveatTooltip>; + return ( + <CopyButton value={info.path} timeout={1500}> + {({ copied, copy }) => + band( + <CaveatTooltip label={copied ? "Path copied" : tip}> + {/* The accessible name carries the path itself: a screen-reader + user gets no benefit from a clipboard they cannot inspect, so + the one place the location is still *readable* is here. */} + <CopyTarget + onClick={copy} + aria-label={`Copy secrets file path: ${info.path}`} + > + {row} + </CopyTarget> + </CaveatTooltip>, + ) + } + </CopyButton> + ); } diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx index 097165c5..927b5c2e 100644 --- a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -495,7 +495,9 @@ describe("ClientSettingsModal", () => { // Full-text assertion: the "Secrets:" prefix is its own `<span>`, which // `getByText`'s direct-text-node matching would not see as one string. const band = screen.getByTestId("secret-storage-footer"); - expect(band).toHaveTextContent("Secrets: File (unencrypted)"); + expect(band).toHaveTextContent( + "Secrets: Plaintext file. Owner-only permissions.", + ); expect(band).toHaveAttribute("data-tone", "warn"); }); diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 5bfcd48b..12e9c397 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -76,9 +76,7 @@ describe("secretStorageCaveat", () => { }); it("names the loss for memory", () => { - expect(secretStorageCaveat(memory)).toContain( - "lost on exit", - ); + expect(secretStorageCaveat(memory)).toContain("lost on exit"); }); it("names the fix for an unencrypted file, not just the problem", () => { From decc673a5db33fcab9f64dec504071073ba23c37 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 14:15:35 -0400 Subject: [PATCH 239/941] fix(auth): address Copilot review round 5, and land the impl its tests need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repairs origin as well as answering the review. 2d2c787e committed the `absorbFileSecretsIntoKeyring` tests without the implementation they import, so the branch has been failing `tsc` since; this commit brings the source across. Round 5's four findings (two inline, two suppressed): - **The stale-lock takeover was still racy.** Round 4 made *stealing* liveness-aware but left the takeover as remove-then-recreate, which is not an election: two waiters can both judge the same lock stale, the first removes it and takes a fresh one, and the second then removes *that* — both inside the section, which is the lost update the takeover exists to prevent. Claiming is now an atomic `rename` to a token-unique name, so exactly one waiter can take a given stale lock and the losers get ENOENT. A failed claim also falls through to the deadline instead of `continue`ing, so a takeover that can never succeed times out rather than spinning forever on a swallowed error. - **Every `stat` failure was read as "no file yet".** An existing file we cannot inspect (EACCES on the directory, a mode-less filesystem) then produced no warning at all, and the footer went on stating owner-only permissions having verified nothing. There is now a third state, `unknown`, carried end to end: `permissionsUnknown` on the descriptor, a caveat that says the permissions could not be checked, a `warn` tone, and its own branch in the footer sentence — which otherwise would have claimed "Owner-only permissions." by omission, the same bug one layer up. - **mountinfo decoded only `\040`.** The kernel escapes tab, newline and backslash too (`\011`, `\012`, `\134`), so a mounted path containing any of them was compared in its escaped form, matched nothing, and read as the container's writable layer — `memory` selected and persistence silently lost on a box that had arranged for it. Decoded as octal in general now, since the rule is octal-in-general and a future addition would otherwise reintroduce exactly this. - **File-backed secrets vanished once a keychain appeared.** Save secrets without libsecret, install libsecret, and the next run selects the keychain and stops seeing them: still on disk, read by nothing, and nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on the run that finds a keychain, under the rules the two existing plaintext migrations already use — keychain wins on conflict, the file is removed only on complete success, and a file that cannot be read is never deleted, because that is precisely the case where the values would be lost. Tests: a two-waiter race over one stale lock asserting a single holder throughout; `unknown` from `readSecretFilePermissions` through the descriptor to the footer sentence and tone; the hand-off's happy path, keychain-wins, write-failure, undecryptable-file, empty-file, malformed-key, and explicitly-configured-keyring cases; and six more rejected descriptor shapes, including each file-only field on a non-file kind. Full `npm run ci` green — 5,732 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.test.tsx | 22 ++++ .../SecretStorageFooter.tsx | 31 ++++- .../test/core/react/useInitialConfig.test.tsx | 45 +++++++ .../auth/node/file-secret-store.test.ts | 66 ++++++++++ .../auth/node/secret-store-selection.test.ts | 115 ++++++++++++++++-- core/auth/node/file-secret-store.ts | 65 ++++++++-- core/auth/node/secret-store-selection.ts | 113 ++++++++++++++++- core/auth/secret-storage-info.ts | 20 +++ core/react/useInitialConfig.ts | 9 +- 9 files changed, 459 insertions(+), 27 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index d8b836da..3eec7f5a 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -112,4 +112,26 @@ describe("SecretStorageFooter", () => { renderWithMantine(<SecretStorageFooter info={undefined} />); expect(screen.queryByTestId("secret-storage-footer")).toBeNull(); }); + + it("says the permissions could not be checked rather than claiming owner-only", async () => { + // The third permission state. Falling into the verified-0600 branch here + // would have the band assert owner-only having read nothing — the same + // bug as claiming 0600 in the caveat, one layer up. + renderWithMantine( + <SecretStorageFooter + info={{ + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/home/node/.mcp-inspector/secrets.json", + permissionsUnknown: "EACCES", + }} + />, + ); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Permissions could not be checked."); + expect(band).not.toHaveTextContent("Owner-only"); + expect(band).toHaveAttribute("data-tone", "warn"); + }); }); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 29645107..800e697e 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -127,6 +127,28 @@ const formatMode = (mode: number): string => mode.toString(8).padStart(4, "0"); * and the underlying facts still come from the shared helpers, which is * what keeps them from disagreeing about anything that matters. */ +/** + * How the band describes the file's permissions. + * + * Three states, not two, and the third is the point: "Owner-only + * permissions." is a claim of *fact*, so it may only be made when the mode + * was actually read and found to be 0600. `looseMode` says we looked and it + * was wrong; `permissionsUnknown` says we could not look at all. Treating + * the second like the first — which a plain `looseMode === undefined` test + * does — makes the footer assert owner-only having verified nothing, which + * is the failure this band exists to prevent, arrived at by omission rather + * than by error. + */ +function filePermissionsSentence(info: SecretStorageInfo): string { + if (info.looseMode !== undefined) { + return `Mode ${formatMode(info.looseMode)} — not owner-only.`; + } + if (info.permissionsUnknown !== undefined) { + return "Permissions could not be checked."; + } + return "Owner-only permissions."; +} + function footerMessage(info: SecretStorageInfo): string { if (info.kind === "keyring") return secretStorageLabel(info); if (info.kind === "memory") { @@ -143,10 +165,7 @@ function footerMessage(info: SecretStorageInfo): string { // is something else and could not be tightened, and saying "owner-only" // there would be the footer asserting the opposite of what is true — the // one failure this whole band exists to prevent. - const permissions = - info.looseMode === undefined - ? "Owner-only permissions." - : `Mode ${formatMode(info.looseMode)} — not owner-only.`; + const permissions = filePermissionsSentence(info); return `${encryption} file. ${permissions}`; } @@ -173,6 +192,10 @@ function footerTooltip(info: SecretStorageInfo): string | undefined { parts.push( `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, ); + } else if (info.permissionsUnknown !== undefined) { + parts.push( + `The file's permissions could not be read (${info.permissionsUnknown}), so it is not known whether others can read it.`, + ); } parts.push("Click to copy file path"); return parts.join(" "); diff --git a/clients/web/src/test/core/react/useInitialConfig.test.tsx b/clients/web/src/test/core/react/useInitialConfig.test.tsx index 33523bbc..799cb39f 100644 --- a/clients/web/src/test/core/react/useInitialConfig.test.tsx +++ b/clients/web/src/test/core/react/useInitialConfig.test.tsx @@ -284,6 +284,7 @@ describe("useInitialConfig", () => { path: "/home/node/.mcp-inspector/secrets.json", pendingEncryption: true, looseMode: 0o644, + permissionsUnknown: "EACCES", }; const fetchFn = vi .fn() @@ -354,6 +355,50 @@ describe("useInitialConfig", () => { looseMode: "644", }, ], + [ + "a non-string permissionsUnknown", + { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/x", + permissionsUnknown: true, + }, + ], + [ + // Each file-only field is rejected on its own, so no single one can + // slip through on the back of the others being absent. + "permissionsUnknown on a keyring descriptor", + { + kind: "keyring", + reason: "default", + durable: true, + permissionsUnknown: "EACCES", + }, + ], + [ + "looseMode on a keyring descriptor", + { kind: "keyring", reason: "default", durable: true, looseMode: 0o644 }, + ], + [ + "pendingEncryption on a memory descriptor", + { + kind: "memory", + reason: "fallback", + durable: false, + pendingEncryption: true, + }, + ], + [ + "a path on a memory descriptor", + { + kind: "memory", + reason: "fallback", + durable: false, + path: "/x/secrets.json", + }, + ], [ // File-only fields on a non-file kind mean the payload was not built // by a backend we understand; rendering a mixture of two stores' diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index bd2c9820..744c300e 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -452,6 +452,38 @@ describe("tightenSecretFilePermissions", () => { expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600); }); + it("reports unknown, not absent, when an existing file cannot be inspected", async () => { + // Collapsing every stat failure into "absent" is what made it dangerous: + // the descriptor then omits the warning and the footer states mode 0600 + // as fact, having verified nothing. + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + const denied = Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + return { + ...actual, + default: actual, + stat: vi.fn(() => Promise.reject(denied)), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/file-secret-store.js"); + expect(await mod.readSecretFilePermissions(filePath())).toEqual({ + state: "unknown", + detail: "EACCES", + }); + } finally { + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + }); + it("readSecretFilePermissions reports the mode without changing it", async () => { // The descriptor is rebuilt on every GET /api/config, so the function it // calls must not chmod — once per page load, on a file another process @@ -905,6 +937,40 @@ describe("acquireLock", () => { await fs.rm(lockDir, { recursive: true, force: true }); }); + it("elects a single winner when two waiters find the same stale lock", async () => { + // `rm`-then-`mkdir` is not an election: both waiters judge the lock + // stale, the first removes it and takes a fresh one, and the second then + // removes *that* — putting both inside the section at once, which is the + // lost update the takeover exists to prevent. The rename is atomic, so + // exactly one waiter can claim a given stale lock. + const target = path.join(tmpDir, "elected.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile( + path.join(lockDir, "owner"), + // A pid that cannot be running, so both waiters agree it is stale. + JSON.stringify({ token: "dead", pid: 2 ** 31 - 1, host: os.hostname() }), + "utf-8", + ); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + let inside = 0; + let maxInside = 0; + const timings = { staleMs: 50, timeoutMs: 10_000, pollMs: 5 }; + const run = async () => { + const release = await acquireLock(target, timings); + inside += 1; + maxInside = Math.max(maxInside, inside); + await new Promise((r) => setTimeout(r, 40)); + inside -= 1; + await release(); + }; + await Promise.all([run(), run()]); + // Never two holders at once. + expect(maxInside).toBe(1); + }); + it("cleans up and reports when it cannot stamp ownership", async () => { // mkdir succeeded, the owner write did not. Returning a no-op release // here would enter the critical section unlocked *and* leave the diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 9670ac44..04059d3b 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -612,29 +612,71 @@ describe("absorbFileSecretsIntoKeyring", () => { it("does not delete a file it cannot read", async () => { // An undecryptable file is not an empty one. Deleting it here would lose - // exactly the values this whole hand-off exists to preserve. + // exactly the values this hand-off exists to preserve. const filePath = path.join(tmpDir, "secrets.json"); process.env.MCP_INSPECTOR_SECRET_FILE = filePath; - process.env.MCP_INSPECTOR_SECRET_KEY = "right-key"; - const seeded = await loadWithProbe(false); vi.spyOn(console, "warn").mockImplementation(() => {}); - await new ( - await import("@inspector/core/auth/node/file-secret-store.js") - ).FileSecretStore({ filePath, passphrase: "right-key" }).set( + + const { FileSecretStore } = + await import("@inspector/core/auth/node/file-secret-store.js"); + await new FileSecretStore({ filePath, passphrase: "right-key" }).set( "srv", "oauthClientSecret", "encrypted", ); - void seeded; - delete process.env.MCP_INSPECTOR_SECRET_KEY; + // The passphrase is gone by the time the keychain reappears. + const mod = await loadWithProbe(true); + const keyring = new InMemorySecretStore(); + await mod.absorbFileSecretsIntoKeyring(keyring); + + expect(existsSync(filePath)).toBe(true); + expect(await keyring.get("srv", "oauthClientSecret")).toBe(null); + }); + + it("is a no-op for a file that holds no entries", async () => { + // An empty map is not a hand-off. Deleting the file here would be + // harmless but pointless; the interesting part is that it does not warn + // about a migration that never happened. + const filePath = await seedFile({}); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + await mod.absorbFileSecretsIntoKeyring(new InMemorySecretStore()); + expect(existsSync(filePath)).toBe(true); + expect(warn).not.toHaveBeenCalled(); + }); + + it("skips an entry whose account key is malformed", async () => { + // A hand-edited file can carry a key that is not `serverId:field`. It + // cannot be addressed through the store API, so it is passed over rather + // than guessed at — but it must not abort the entries around it. + const filePath = await seedFile({ + "no-separator": "orphan", + "srv:oauthClientSecret": "from-file", + }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + vi.spyOn(console, "warn").mockImplementation(() => {}); const mod = await loadWithProbe(true); const keyring = new InMemorySecretStore(); await mod.absorbFileSecretsIntoKeyring(keyring); - expect(existsSync(filePath)).toBe(true); - expect(await keyring.get("srv", "oauthClientSecret")).toBe(null); + expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file"); + }); + + it("runs the hand-off for an explicitly configured keyring too", async () => { + // `MCP_INSPECTOR_SECRET_STORE=keyring` reaches the keychain by a + // different path than the probe does, and a leftover file is just as + // invisible either way. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "keyring"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + // Resolving is what triggers it, via the configured branch. + await mod.resolveSecretStore(); + expect(existsSync(filePath)).toBe(false); }); it("is a no-op when there is no file", async () => { @@ -646,6 +688,59 @@ describe("absorbFileSecretsIntoKeyring", () => { }); }); +describe("the descriptor reports a mode it could not verify", () => { + it("carries permissionsUnknown when the file exists but cannot be inspected", async () => { + // The third state. Without it an unverifiable mode fell into the same + // branch as a verified 0600, and the footer claimed owner-only + // permissions having read nothing. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + await fs.writeFile( + filePath, + JSON.stringify({ version: 1, encryption: "none", secrets: {} }), + "utf-8", + ); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + const denied = Object.assign(new Error("EACCES: permission denied"), { + code: "EACCES", + }); + return { + ...actual, + default: actual, + stat: vi.fn(() => Promise.reject(denied)), + }; + }); + vi.doMock("@inspector/core/auth/node/secret-store.js", async () => { + const actual = await vi.importActual< + typeof import("@inspector/core/auth/node/secret-store.js") + >("@inspector/core/auth/node/secret-store.js"); + return { + ...actual, + probeKeyringAvailable: vi.fn(async () => ({ available: true })), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/secret-store-selection.js"); + const info = await mod.getSecretStorageInfo(); + expect(info.permissionsUnknown).toBe("EACCES"); + expect(info.looseMode).toBeUndefined(); + } finally { + vi.doUnmock("node:fs/promises"); + vi.doUnmock("@inspector/core/auth/node/secret-store.js"); + vi.resetModules(); + } + }); +}); + describe("the descriptor reports a mode it could not tighten", () => { it("carries looseMode into the descriptor and warns once at selection", async () => { // The warning alone was not enough: the caveat under the secret field diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 9b954a91..06e7a4bb 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -382,8 +382,29 @@ export async function acquireLock( } if (code !== "EEXIST") return noop; // cannot lock here at all — see above if (await canSteal(lockDir, ownerFile, staleMs)) { - await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {}); - continue; + // Claim the stale lock by *renaming* it, not by removing it. + // + // `rm` is not an election: two waiters can both judge the same lock + // stale, and by the time the second one runs, the first may already + // have removed it and created a fresh lock of its own — which the + // second then deletes, and both enter the section together. That is + // the lost update, reached through the takeover meant to prevent it. + // + // `rename` is atomic and single-winner: exactly one waiter moves the + // directory aside, and the losers get ENOENT and fall through to + // retry against whatever the winner does next. The unique + // destination (our token) also means two winners of *different* + // stale locks cannot collide. + const claimed = `${lockDir}.stale-${token}`; + try { + await fs.rename(lockDir, claimed); + await fs.rm(claimed, { recursive: true, force: true }); + continue; + } catch { + // Lost the election, or the rename failed outright. Either way, + // fall through to the deadline check rather than looping — a + // takeover that can never succeed must time out, not spin. + } } if (Date.now() >= deadline) throw new SecretFileLockTimeoutError(filePath); @@ -566,6 +587,25 @@ export class FileSecretStore implements SecretStore { * throw for "there is a file and we cannot read it" — which must never be * flattened into an empty map, or `set` would cheerfully overwrite it. */ + /** + * Every secret in the file, keyed by `${serverId}:${field}` — `null` when + * there is no file. + * + * Exists for one caller: the hand-off performed when a keychain becomes + * available on a box that had been falling back to a file (see + * `absorbFileSecretsIntoKeyring`). Nothing in the normal request path + * enumerates secrets, and deliberately so — this is the widest possible + * read of the store and it should stay a one-purpose seam rather than a + * general convenience. + * + * Throws for the same reasons `get` swallows: an unreadable or + * undecryptable file must not present as "no secrets", because the caller + * would conclude there was nothing to migrate and move on. + */ + async readAll(): Promise<Record<string, string> | null> { + return this.serialize(() => this.readMap()); + } + private async readMap(): Promise<Record<string, string> | null> { const raw = await readStoreFile(this.filePath); if (raw === null) return null; @@ -830,10 +870,19 @@ export async function readSecretFilePermissions( const mode = (await fs.stat(filePath)).mode & 0o777; if (mode === 0o600) return { state: "ok" }; return { state: "loose", mode }; - } catch { - // No file yet — the common first run. `writeStoreFile` creates it 0600, - // so there is nothing to warn about. - return { state: "absent" }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + // No file yet — the common first run. `writeStoreFile` creates it + // 0600, so there is nothing to warn about. + return { state: "absent" }; + } + // The file exists and we could not inspect it (EACCES on the directory, + // a permission-less filesystem). Collapsing this into "absent" is what + // makes it dangerous: the descriptor then omits the warning and the + // footer goes on stating mode 0600 as fact, having verified nothing. + // "Unknown" is the honest third answer. + return { state: "unknown", detail: code ?? "unknown error" }; } } @@ -846,4 +895,6 @@ export async function readSecretFilePermissions( export type SecretFilePermissionState = | { state: "ok" } | { state: "absent" } - | { state: "loose"; mode: number }; + | { state: "loose"; mode: number } + /** The file is there and its mode could not be read — see above. */ + | { state: "unknown"; detail: string }; diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index c4fba79d..bcb061a9 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -40,6 +40,7 @@ */ import * as fsSync from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { type SecretStorageInfo, @@ -54,6 +55,7 @@ import { } from "./file-secret-store.js"; import { KeyringSecretStore, + parseAccount, probeKeyringAvailable, secretStoreIsDurable, SessionSecretStore, @@ -186,10 +188,31 @@ function nearestExisting(dir: string): string | null { * `null` where that file cannot be read (macOS, Windows, a `/proc`-less * container). * - * Field 5 of each line is the mount point, with spaces escaped as `\040`. - * `/` is dropped: in a container that is the writable layer, which is - * exactly what the durability question is asking *against*. + * Field 5 of each line is the mount point, octal-escaped. `/` is dropped: + * in a container that is the writable layer, which is exactly what the + * durability question is asking *against*. */ +/** + * Undo the kernel's octal escaping of a mountinfo path. + * + * The field is space-delimited, so `show_mountinfo` escapes anything that + * would break parsing — space (`\040`), tab (`\011`), newline (`\012`) and + * the backslash itself (`\134`). Decoding only the space left a mounted + * path containing any of the others compared in its escaped form, so it + * matched nothing, and a genuinely mounted volume read as the container's + * writable layer: `memory` selected, persistence silently lost on a box + * that had arranged for it. + * + * Handled as one general `\ooo` pass rather than four literals, since the + * escaping rule is octal-in-general and a future addition would otherwise + * reintroduce exactly this bug. + */ +function unescapeMountPoint(point: string): string { + return point.replace(/\\(\d{3})/g, (_match, octal: string) => + String.fromCharCode(parseInt(octal, 8)), + ); +} + function readMountPoints(): string[] | null { try { return fsSync @@ -197,7 +220,7 @@ function readMountPoints(): string[] | null { .split("\n") .map((line) => line.split(" ")[4]) .filter((point): point is string => Boolean(point)) - .map((point) => point.replace(/\\040/g, " ")) + .map(unescapeMountPoint) .filter((point) => point !== "/"); } catch { return null; @@ -325,6 +348,7 @@ async function describeFileStore( path: filePath, plaintext, ...(perms.state === "loose" ? { looseMode: perms.mode } : {}), + ...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}), // Only meaningful while the two disagree; omitted otherwise so the // payload does not carry a flag that says nothing. ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), @@ -385,11 +409,18 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> { const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]); if (configured) { const result = await buildStore(configured, "configured"); + if (configured === "keyring") { + await absorbFileSecretsIntoKeyring(result.store); + } warnAboutSecretStorage(result.info); return result; } const probe = await probeKeyringAvailable(); - if (probe.available) return buildStore("keyring", "default"); + if (probe.available) { + const result = await buildStore("keyring", "default"); + await absorbFileSecretsIntoKeyring(result.store); + return result; + } const kind = chooseFallbackKind({ container: isContainer(), @@ -402,6 +433,78 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> { return resolved; } +/** + * Hand a leftover secrets file over to the keychain, once one exists. + * + * The transition this closes: a box with no libsecret falls back to + * `secrets.json` and the user saves an OAuth client secret there. They + * install libsecret. The next run probes successfully, selects the keychain + * — and every value they saved becomes invisible, because nothing reads the + * file any more. The data is still on disk, which makes it worse rather + * than better: nothing looks broken, the secrets have simply gone, and the + * file sits there as an unexplained leftover. + * + * The rules are the ones the two existing plaintext migrations already use, + * for consistency rather than novelty: + * + * - **The keychain wins on conflict.** An account already present there is + * left alone; the file's value is treated as the older copy. + * - **The file is removed only on complete success.** A partial hand-off + * leaves it exactly as it was, so the next run retries and the + * keychain-wins rule silently absorbs whatever already made it across. + * - **An unreadable file is not an empty one.** If it cannot be decrypted + * (the passphrase changed, or is now unset) this reports and returns + * rather than deleting it — the whole point is not to lose the values. + * + * Never throws. A keychain that fails mid-hand-off leaves the file intact, + * which is the safe state, and the user still gets a working session. + */ +export async function absorbFileSecretsIntoKeyring( + keyring: SecretStore, +): Promise<void> { + const filePath = defaultSecretFilePath(); + if (!fsSync.existsSync(filePath)) return; + + const file = new FileSecretStore({ filePath }); + let entries: Record<string, string> | null; + try { + entries = await file.readAll(); + } catch (err) { + console.warn( + `\n[mcp-inspector] The OS keychain is available again, but the secrets file at ${filePath} could not be read, so its contents were left there: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + if (!entries || Object.keys(entries).length === 0) return; + + try { + for (const [account, value] of Object.entries(entries)) { + const parsed = parseAccount(account); + if (!parsed) continue; + const existing = await keyring.get(parsed.serverId, parsed.field); + if (existing === null) { + await keyring.set(parsed.serverId, parsed.field, value); + } + } + } catch (err) { + console.warn( + `\n[mcp-inspector] Could not move the secrets file at ${filePath} into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + + try { + await fs.rm(filePath, { force: true }); + console.warn( + `\n[mcp-inspector] The OS keychain is available again. Secrets kept in ${filePath} while it was not have been moved into the keychain, and the file has been removed.`, + ); + } catch { + // Every value is in the keychain; only the now-redundant file remains. + // Not worth failing startup over, and the keychain-wins rule makes the + // next attempt harmless. + } +} + /** * The active store's descriptor — for the banner and `GET /api/config`. * diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index 5435222c..cfb28756 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -72,6 +72,17 @@ export interface SecretStorageInfo { * and the browser had no way to know. */ looseMode?: number; + /** + * Set when the secrets file exists and its mode could not be read at all + * — `EACCES` on the containing directory, or a filesystem with no POSIX + * modes. Carries the errno so the message can say why. + * + * Distinct from {@link looseMode}, and the distinction matters: that one + * says "we looked and it is wrong", this one says "we could not look". + * Collapsing the second into silence is what let the footer keep stating + * mode 0600 as fact having verified nothing. + */ + permissionsUnknown?: string; /** * True when secrets outlive the process. False only for `memory`, and * it is the *promise* being made rather than an implementation detail: @@ -116,6 +127,9 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone { // An encrypted file that anyone can read is still worth flagging: the // passphrase is the only thing standing between a reader and the values. if (info.looseMode !== undefined) return "warn"; + // Unverified is not the same as fine. The quiet tone here would read as a + // confirmation we never made. + if (info.permissionsUnknown !== undefined) return "warn"; return "neutral"; } @@ -137,6 +151,12 @@ export function secretStorageCaveat( const mode = info.looseMode.toString(8).padStart(4, "0"); return `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`; } + // Ordered with the permission problems, above encryption, for the same + // reason: not knowing whether others can read the file is a question about + // exposure, and it should not be answered by a reassuring silence. + if (info.permissionsUnknown !== undefined) { + return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`; + } if (info.plaintext) { return info.pendingEncryption ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index 91904305..80f8d9b3 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -111,6 +111,12 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { if (v.looseMode !== undefined && typeof v.looseMode !== "number") { return undefined; } + if ( + v.permissionsUnknown !== undefined && + typeof v.permissionsUnknown !== "string" + ) { + return undefined; + } } else if ( // File-only fields on a keychain or memory descriptor mean the payload // was not built by a backend we understand. Rejecting is cheap and the @@ -118,7 +124,8 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { v.path !== undefined || v.plaintext !== undefined || v.pendingEncryption !== undefined || - v.looseMode !== undefined + v.looseMode !== undefined || + v.permissionsUnknown !== undefined ) { return undefined; } From 1aa1f5a0caabc45f21769bca5fc3c5fa243e8a78 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 14:24:09 -0400 Subject: [PATCH 240/941] docs(web): attach the footer doc blocks to the functions they describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change. Adding `filePermissionsSentence` in decc673a inserted it directly under `footerMessage`'s doc block, which left two block comments stacked and each attached to the wrong function: `footerMessage` was undocumented, and its "why not `secretStorageLabel`" rationale read as though it were about the permissions helper, which it is not. Moves that block back above `footerMessage`, and drops the inline comment inside it that now duplicated the new helper's doc — and had gone stale besides, since it still described two permission states rather than three. Caught by @mcp-inspector-v2-f1 on a read of the pushed commit. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.tsx | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 800e697e..901ddf2b 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -117,16 +117,6 @@ const ICON_SIZE = 14; /** Octal mode, as `stat` would print it (`0644`). */ const formatMode = (mode: number): string => mode.toString(8).padStart(4, "0"); -/** - * The visible statement, per store. - * - * Deliberately not built from `secretStorageLabel` for the file case: that - * label is parenthetical ("File (unencrypted)") because the startup banner - * appends " at <path>" to it, and the footer wants a sentence instead. The - * two surfaces have different shapes, so each phrases its own; the *tone* - * and the underlying facts still come from the shared helpers, which is - * what keeps them from disagreeing about anything that matters. - */ /** * How the band describes the file's permissions. * @@ -149,6 +139,16 @@ function filePermissionsSentence(info: SecretStorageInfo): string { return "Owner-only permissions."; } +/** + * The visible statement, per store. + * + * Deliberately not built from `secretStorageLabel` for the file case: that + * label is parenthetical ("File (unencrypted)") because the startup banner + * appends " at <path>" to it, and the footer wants a sentence instead. The + * two surfaces have different shapes, so each phrases its own; the *tone* + * and the underlying facts still come from the shared helpers, which is + * what keeps them from disagreeing about anything that matters. + */ function footerMessage(info: SecretStorageInfo): string { if (info.kind === "keyring") return secretStorageLabel(info); if (info.kind === "memory") { @@ -160,11 +160,6 @@ function footerMessage(info: SecretStorageInfo): string { : secretStorageLabel(info); } const encryption = info.plaintext ? "Plaintext" : "Encrypted"; - // "Owner-only permissions." is a claim of fact, so it is only made when the - // mode was actually verified as 0600. `looseMode` is set precisely when it - // is something else and could not be tightened, and saying "owner-only" - // there would be the footer asserting the opposite of what is true — the - // one failure this whole band exists to prevent. const permissions = filePermissionsSentence(info); return `${encryption} file. ${permissions}`; } From fdeffa1f0077f3ca18e761bee4488c05ed060d54 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 14:40:29 -0400 Subject: [PATCH 241/941] fix(auth): address Copilot review round 6 on the secret-store PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all suppressed, all real. Three are the same mistake in different places: a state that was never established being reported as a confident answer. - **`readOnDiskEncryption` conflated "no file" with "unreadable file".** Both answered `null`, and the descriptor falls back to the configured write policy on `null` — so a passphrase-configured install reported "File (encrypted)" about a file whose envelope had never parsed, and which `set` was in fact about to refuse rather than overwrite. It now returns a four-state result, and only `absent` falls back to the policy (honestly: the first write really will create it that way). `unreadable` produces `encryptionUnknown` on the descriptor, `plaintext` is omitted entirely so nothing can read a default out of it, and the label, caveat, tone and footer all carry "unreadable" rather than guessing a mode. - **The loose-mode warning overstated exposure for an encrypted file.** "Anyone who can read it can read the secrets in it" is false when the file is ciphertext — a reader gets the bytes and still needs the passphrase. Both the caveat and the footer tooltip now branch on encryption: the passphrase becomes the only protection, which is the real consequence. Overstating it is not harmless, because a warning that cries wolf is discounted on the occasion it is literally true. - **The keychain hand-off could invert its own keychain-wins rule.** `SecretStore.get` answers `null` both for a missing entry and for a keychain it could not read, and the migration *writes* on `null` — so a transient read failure would overwrite a newer keychain value with the older file copy. Added an optional `getStrict` (throws instead of swallowing) implemented by `KeyringSecretStore`, with `secretStoreGetStrict` falling back to `get` for stores whose reads cannot fail. A throw now aborts the hand-off with the file left in place, so the next run retries. Writing the tests turned up one more, in code the review had passed: `parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is a *wildcard* asking about every process this user can signal — it answers "alive" almost always, which would have made a lock with a corrupt stamp permanently unstealable. Only a positive pid is usable now; anything else is treated as no pid at all and the mtime decides. Tests: the four envelope states kept distinct; the descriptor refusing to guess, with the genuine no-file case as the control; strict-read abort without a write and with the file preserved, plus a store whose `get` and `getStrict` disagree so the test proves which one the migration consults; encryption-aware wording asserted in both directions in the caveat and the band; three unusable owner stamps and a wildcard pid; and the descriptor shapes that must now be rejected — neither half of the encryption question answered, or both. Full `npm run ci` green — 5,750 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.test.tsx | 39 +++++++ .../SecretStorageFooter.tsx | 18 ++- .../core/auth/secret-storage-info.test.ts | 32 ++++++ .../test/core/react/useInitialConfig.test.tsx | 47 ++++++++ .../auth/node/file-secret-store.test.ts | 103 ++++++++++++++++-- .../auth/node/secret-store-selection.test.ts | 98 +++++++++++++++++ core/auth/node/file-secret-store.ts | 59 +++++++--- core/auth/node/secret-store-selection.ts | 31 +++++- core/auth/node/secret-store.ts | 48 ++++++++ core/auth/secret-storage-info.ts | 29 ++++- core/react/useInitialConfig.ts | 14 ++- 11 files changed, 489 insertions(+), 29 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index 3eec7f5a..a639d91f 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -134,4 +134,43 @@ describe("SecretStorageFooter", () => { expect(band).not.toHaveTextContent("Owner-only"); expect(band).toHaveAttribute("data-tone", "warn"); }); + + it("says an unreadable file is unreadable, not encrypted", async () => { + // The descriptor omits `plaintext` here, and the band must not read a + // default out of that absence. + renderWithMantine( + <SecretStorageFooter + info={{ + kind: "file", + reason: "fallback", + durable: true, + path: "/home/node/.mcp-inspector/secrets.json", + encryptionUnknown: "not valid JSON", + }} + />, + ); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Unreadable file."); + expect(band).toHaveTextContent("Saving a secret will fail."); + expect(band).not.toHaveTextContent("Encrypted file"); + expect(band).toHaveAttribute("data-tone", "warn"); + }); + + it("does not claim a loose-mode encrypted file exposes its secrets", async () => { + renderWithMantine( + <SecretStorageFooter + info={{ + kind: "file", + reason: "fallback", + durable: true, + plaintext: false, + path: "/home/node/.mcp-inspector/secrets.json", + looseMode: 0o644, + }} + />, + ); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveTextContent("Mode 0644 — not owner-only."); + expect(band).toHaveAttribute("data-tone", "warn"); + }); }); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 901ddf2b..8df1234e 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -159,6 +159,11 @@ function footerMessage(info: SecretStorageInfo): string { ? `${secretStorageLabel(info)}: ${caveat}` : secretStorageLabel(info); } + // "Unreadable" rather than guessing a mode: the envelope could not be + // parsed, so neither "Plaintext" nor "Encrypted" is a claim we can make. + if (info.encryptionUnknown !== undefined) { + return "Unreadable file. Saving a secret will fail."; + } const encryption = info.plaintext ? "Plaintext" : "Encrypted"; const permissions = filePermissionsSentence(info); return `${encryption} file. ${permissions}`; @@ -174,7 +179,11 @@ function footerMessage(info: SecretStorageInfo): string { function footerTooltip(info: SecretStorageInfo): string | undefined { if (info.kind !== "file" || !info.path) return undefined; const parts: string[] = []; - if (info.plaintext) { + if (info.encryptionUnknown !== undefined) { + parts.push( + `The file could not be read (${info.encryptionUnknown}), so its contents are unknown and saving a secret will fail rather than overwrite it.`, + ); + } else if (info.plaintext) { // Advice that can actually clear the condition: someone who has already // set the passphrase is waiting on the next write, not on themselves. parts.push( @@ -184,8 +193,13 @@ function footerTooltip(info: SecretStorageInfo): string | undefined { ); } if (info.looseMode !== undefined) { + // Encryption-aware: a reader of an encrypted file gets ciphertext, not + // secrets. Claiming otherwise overstates the exposure, and a warning + // that cries wolf is discounted on the occasion it is literally true. parts.push( - `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, + info.plaintext === false + ? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.` + : `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, ); } else if (info.permissionsUnknown !== undefined) { parts.push( diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 12e9c397..486b03dd 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -121,6 +121,38 @@ describe("secretStorageCaveat", () => { expect(secretStorageCaveat(loose)).toContain("0640"); }); + it("does not overstate exposure for an encrypted file with a loose mode", () => { + // A reader of an encrypted file gets ciphertext, not secrets. Saying + // otherwise trains people to discount the warning on the occasion it is + // literally true. + const loose: SecretStorageInfo = { ...encryptedFile, looseMode: 0o644 }; + const caveat = secretStorageCaveat(loose); + expect(caveat).toContain("0644"); + expect(caveat).toContain("passphrase is then the only thing"); + expect(caveat).not.toContain("can read the secrets in it"); + }); + + it("keeps the blunt wording for a plaintext file with a loose mode", () => { + const loose: SecretStorageInfo = { ...plaintextFile, looseMode: 0o644 }; + expect(secretStorageCaveat(loose)).toContain("can read the secrets in it"); + }); + + it("says a file it could not read is unreadable, and warns about the next save", () => { + const unreadable: SecretStorageInfo = { + kind: "file", + reason: "fallback", + durable: true, + path: "/x/secrets.json", + encryptionUnknown: "not valid JSON", + }; + // Neither "encrypted" nor "unencrypted" is a claim we can make here. + expect(secretStorageLabel(unreadable)).toBe("File (unreadable)"); + expect(secretStorageTone(unreadable)).toBe("warn"); + expect(secretStorageCaveat(unreadable)).toMatch( + /could not be read \(not valid JSON\)\. Saving a secret will fail/, + ); + }); + it("prefers the memory caveat when a memory store somehow carries the flag", () => { // Defensive ordering: `plaintext` is meaningless for memory, and the // durability loss is the more consequential of the two statements. diff --git a/clients/web/src/test/core/react/useInitialConfig.test.tsx b/clients/web/src/test/core/react/useInitialConfig.test.tsx index 799cb39f..a94980df 100644 --- a/clients/web/src/test/core/react/useInitialConfig.test.tsx +++ b/clients/web/src/test/core/react/useInitialConfig.test.tsx @@ -296,6 +296,26 @@ describe("useInitialConfig", () => { expect(result.current.secretStorage).toEqual(info); }); + it("passes through a descriptor that reports an unreadable file", async () => { + // `plaintext` is legitimately absent here; the guard must accept that + // shape rather than requiring a field the backend cannot honestly set. + const info = { + kind: "file", + reason: "fallback", + durable: true, + path: "/home/node/.mcp-inspector/secrets.json", + encryptionUnknown: "not valid JSON", + }; + const fetchFn = vi + .fn() + .mockResolvedValue(jsonResponse({ secretStorage: info })); + const { result } = renderHook(() => + useInitialConfig({ baseUrl: "http://test.local", fetchFn }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.secretStorage).toEqual(info); + }); + it("is undefined on a backend that omits the field", async () => { const fetchFn = vi.fn().mockResolvedValue(jsonResponse({})); const { result } = renderHook(() => @@ -355,6 +375,33 @@ describe("useInitialConfig", () => { looseMode: "644", }, ], + [ + // Neither half of the encryption question answered. + "a file descriptor with neither plaintext nor encryptionUnknown", + { kind: "file", reason: "fallback", durable: true, path: "/x" }, + ], + [ + // Both answered, which is contradictory — the state was either read + // or it wasn't. + "a file descriptor with both plaintext and encryptionUnknown", + { + kind: "file", + reason: "fallback", + durable: true, + path: "/x", + plaintext: true, + encryptionUnknown: "not valid JSON", + }, + ], + [ + "encryptionUnknown on a memory descriptor", + { + kind: "memory", + reason: "fallback", + durable: false, + encryptionUnknown: "not valid JSON", + }, + ], [ "a non-string permissionsUnknown", { diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 744c300e..69f6df4b 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -484,6 +484,33 @@ describe("tightenSecretFilePermissions", () => { } }); + it("names an errno-less stat failure rather than printing undefined", async () => { + vi.resetModules(); + vi.doMock("node:fs/promises", async () => { + const actual = + await vi.importActual<typeof import("node:fs/promises")>( + "node:fs/promises", + ); + // No `code` property — a rejection shaped unlike an errno error. + return { + ...actual, + default: actual, + stat: vi.fn(() => Promise.reject(new Error("something odd"))), + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/file-secret-store.js"); + expect(await mod.readSecretFilePermissions(filePath())).toEqual({ + state: "unknown", + detail: "unknown error", + }); + } finally { + vi.doUnmock("node:fs/promises"); + vi.resetModules(); + } + }); + it("readSecretFilePermissions reports the mode without changing it", async () => { // The descriptor is rebuilt on every GET /api/config, so the function it // calls must not chmod — once per page load, on a file another process @@ -560,25 +587,40 @@ describe("readOnDiskEncryption", () => { passphrase: "hunter2", }); expect(withKey.encrypted).toBe(true); - expect(await withKey.readOnDiskEncryption()).toBe("none"); + expect(await withKey.readOnDiskEncryption()).toEqual({ + state: "plaintext", + }); await withKey.set("alpha", "env:A", "1"); - expect(await withKey.readOnDiskEncryption()).toBe("aes-256-gcm"); + expect(await withKey.readOnDiskEncryption()).toEqual({ + state: "encrypted", + }); }); - it("answers null for an absent, unparseable, or unrecognized file", async () => { + it("keeps 'no file yet' apart from 'there is a file and we cannot read it'", async () => { + // Collapsing these is what let a passphrase-configured install claim + // "Encrypted file" about a corrupt file `set` was about to refuse: the + // descriptor falls back to the write policy on `absent`, which is only + // honest when the first write really will create the file that way. const store = new FileSecretStore({ filePath: filePath() }); - expect(await store.readOnDiskEncryption()).toBe(null); + expect(await store.readOnDiskEncryption()).toEqual({ state: "absent" }); await fs.writeFile(filePath(), "{not json", "utf-8"); - expect(await store.readOnDiskEncryption()).toBe(null); + expect(await store.readOnDiskEncryption()).toEqual({ + state: "unreadable", + detail: "not valid JSON", + }); await fs.writeFile( filePath(), JSON.stringify({ version: 1, encryption: "rot13" }), "utf-8", ); - expect(await store.readOnDiskEncryption()).toBe(null); + const unsupported = await store.readOnDiskEncryption(); + expect(unsupported.state).toBe("unreadable"); + expect( + unsupported.state === "unreadable" ? unsupported.detail : "", + ).toMatch(/unsupported envelope/); }); it("needs no passphrase — it reads the envelope, never the payload", async () => { @@ -589,7 +631,9 @@ describe("readOnDiskEncryption", () => { }); await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); const keyless = new FileSecretStore({ filePath: filePath() }); - expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm"); + expect(await keyless.readOnDiskEncryption()).toEqual({ + state: "encrypted", + }); }); }); @@ -884,6 +928,51 @@ describe("acquireLock", () => { await release(); }); + it.each([ + ["not JSON at all", "{ not json"], + ["JSON that is not an object", '"just-a-string"'], + ["an object with no token", JSON.stringify({ pid: 1, host: "h" })], + ])("steals a stale lock whose owner stamp is %s", async (_label, stamp) => { + // An unusable stamp cannot answer the liveness question, so the mtime is + // all there is — and stealing on it is right, because the alternative is + // a lock nothing can ever break. + const target = path.join(tmpDir, `stamp-${_label.length}.json`); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile(path.join(lockDir, "owner"), stamp, "utf-8"); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + const release = await acquireLock(target, { + staleMs: 50, + timeoutMs: 2_000, + pollMs: 10, + }); + await release(); + }); + + it("steals a stale lock whose stamp carries no usable pid", async () => { + // A stamp with a non-numeric pid names no process to ask about, so it + // cannot vouch for the holder being alive. + const target = path.join(tmpDir, "nopid.json"); + const lockDir = `${target}.lock`; + await fs.mkdir(lockDir, { recursive: true }); + await fs.writeFile( + path.join(lockDir, "owner"), + JSON.stringify({ token: "t", pid: "not-a-number", host: os.hostname() }), + "utf-8", + ); + const old = new Date(Date.now() - 60_000); + await fs.utimes(lockDir, old, old); + + const release = await acquireLock(target, { + staleMs: 50, + timeoutMs: 2_000, + pollMs: 10, + }); + await release(); + }); + it("steals a stale lock stamped by another machine", async () => { // A shared filesystem: we cannot ask this host about that host's pid, so // the mtime is all there is. Refusing would make a lock nothing can ever diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 04059d3b..a18a1a8f 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -634,6 +634,63 @@ describe("absorbFileSecretsIntoKeyring", () => { expect(await keyring.get("srv", "oauthClientSecret")).toBe(null); }); + it("aborts rather than overwriting when the keychain read fails", async () => { + // `get` answers null for an unreachable keychain as well as a missing + // entry, and the hand-off *writes* on null — so a transient read failure + // would replace a newer keychain value with the older file copy, exactly + // inverting the keychain-wins rule. The strict read turns that into a + // throw, which leaves the file in place for the next run. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + + let wrote = false; + const flaky: SecretStore = { + get: async () => null, + getStrict: async () => { + throw new Error("keychain temporarily unavailable"); + }, + set: async () => { + wrote = true; + }, + delete: async () => {}, + deleteAllForServer: async () => {}, + }; + + await mod.absorbFileSecretsIntoKeyring(flaky); + + expect(wrote).toBe(false); + expect(existsSync(filePath)).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("left in place")); + }); + + it("uses the strict read when the store offers one", async () => { + // And when it answers "present", the keychain still wins. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + + let wrote = false; + const store: SecretStore = { + // Deliberately disagrees with `getStrict` so the test proves which one + // the migration consults. + get: async () => null, + getStrict: async () => "already-here", + set: async () => { + wrote = true; + }, + delete: async () => {}, + deleteAllForServer: async () => {}, + }; + + await mod.absorbFileSecretsIntoKeyring(store); + + expect(wrote).toBe(false); + expect(existsSync(filePath)).toBe(false); + }); + it("is a no-op for a file that holds no entries", async () => { // An empty map is not a hand-off. Deleting the file here would be // harmless but pointless; the interesting part is that it does not warn @@ -688,6 +745,47 @@ describe("absorbFileSecretsIntoKeyring", () => { }); }); +describe("the descriptor refuses to guess an unreadable file's encryption", () => { + it("reports encryptionUnknown instead of falling back to the write policy", async () => { + // With a passphrase configured, the old fallback claimed "File + // (encrypted)" about a file whose envelope had never been parsed — and + // which `set` was about to refuse rather than overwrite. + const filePath = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + await fs.writeFile(filePath, "{ not json", "utf-8"); + vi.spyOn(console, "warn").mockImplementation(() => {}); + + const mod = await loadWithProbe(false); + const info = await mod.getSecretStorageInfo(); + + expect(info.encryptionUnknown).toBe("not valid JSON"); + // No half-answer left for a consumer to read a default out of. + expect(info.plaintext).toBeUndefined(); + expect(info.pendingEncryption).toBeUndefined(); + + const { secretStorageLabel, secretStorageCaveat, secretStorageTone } = + await import("@inspector/core/auth/secret-storage-info.js"); + expect(secretStorageLabel(info)).toBe("File (unreadable)"); + expect(secretStorageTone(info)).toBe("warn"); + expect(secretStorageCaveat(info)).toMatch(/Saving a secret will fail/); + }); + + it("still uses the write policy when there is genuinely no file", async () => { + // The control: `absent` is the one state where reporting the policy is + // honest, because the first write really will create it that way. + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "none.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + const info = await mod.getSecretStorageInfo(); + expect(info.encryptionUnknown).toBeUndefined(); + expect(info.plaintext).toBe(false); + }); +}); + describe("the descriptor reports a mode it could not verify", () => { it("carries permissionsUnknown when the file exists but cannot be inspected", async () => { // The third state. Without it an unverifiable mode fell into the same diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 06e7a4bb..35bf92bc 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -456,7 +456,7 @@ async function canSteal( } catch { // No stamp to consult; fall through to the time-only answer. } - if (!owner || owner.host !== os.hostname() || typeof owner.pid !== "number") { + if (!owner || owner.host !== os.hostname() || owner.pid === undefined) { return true; } return !processIsAlive(owner.pid); @@ -475,7 +475,8 @@ function processIsAlive(pid: number): boolean { interface LockOwner { token: string; - pid: number; + /** Absent when the stamp carried no usable (positive) pid. */ + pid?: number; host: string; } @@ -487,8 +488,13 @@ function parseOwner(raw: string): LockOwner | null { const { token, pid, host } = parsed as Partial<LockOwner>; if (typeof token !== "string") return null; return { + // Only a *positive* pid is usable. `-1` and `0` are wildcards to + // `process.kill` — `kill(-1, 0)` asks about every process this user + // can signal and answers "alive" for almost any host, which would make + // a garbled stamp permanently unstealable. Anything else is recorded + // as absent so the caller falls back to the mtime. + pid: typeof pid === "number" && pid > 0 ? pid : undefined, token, - pid: typeof pid === "number" ? pid : -1, host: String(host), }; } catch { @@ -496,6 +502,17 @@ function parseOwner(raw: string): LockOwner | null { } } +/** + * What the secrets file's envelope says, or why it could not be read. + * `absent` is "nothing written yet"; `unreadable` is "there is a file and + * we cannot tell" — two very different things to say to a user. + */ +export type SecretFileEncryptionState = + | { state: "absent" } + | { state: "plaintext" } + | { state: "encrypted" } + | { state: "unreadable"; detail: string }; + export interface FileSecretStoreOptions { /** Absolute path of the secrets file. */ filePath: string; @@ -550,20 +567,36 @@ export class FileSecretStore implements SecretStore { * Reads the envelope only. It never decrypts, so it needs no passphrase * and answers correctly even when the key is wrong or missing — which is * also a state the UI has to describe rather than throw on. + * + * `absent` and `unreadable` are kept apart deliberately. Collapsing them + * (as a bare `null` did) means an existing file that is corrupt, or + * written in an envelope this build does not know, is reported as "no + * file yet" — and the descriptor then falls back to the configured write + * policy, so a passphrase-configured install claims "Encrypted file" + * about a file whose encryption state was never established and which + * `set` is in fact about to refuse. */ - async readOnDiskEncryption(): Promise<SecretFile["encryption"] | null> { + async readOnDiskEncryption(): Promise<SecretFileEncryptionState> { + let raw: string | null; + try { + raw = await readStoreFile(this.filePath); + } catch (err) { + return { + state: "unreadable", + detail: err instanceof Error ? err.message : String(err), + }; + } + if (raw === null) return { state: "absent" }; try { - const raw = await readStoreFile(this.filePath); - if (raw === null) return null; const parsed = JSON.parse(raw) as SecretFile; - return parsed.encryption === "none" || parsed.encryption === CIPHER - ? parsed.encryption - : null; + if (parsed.encryption === "none") return { state: "plaintext" }; + if (parsed.encryption === CIPHER) return { state: "encrypted" }; + return { + state: "unreadable", + detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`, + }; } catch { - // Absent, unreadable, or not JSON. "Unknown" is the honest answer, and - // the caller falls back to reporting the configured policy — the only - // thing that is knowable about a file nobody can read. - return null; + return { state: "unreadable", detail: "not valid JSON" }; } } diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index bcb061a9..536e0761 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -57,6 +57,7 @@ import { KeyringSecretStore, parseAccount, probeKeyringAvailable, + secretStoreGetStrict, secretStoreIsDurable, SessionSecretStore, type SecretStore, @@ -334,7 +335,14 @@ async function describeFileStore( detail?: string, ): Promise<SecretStorageInfo> { const onDisk = await store.readOnDiskEncryption(); - const plaintext = onDisk === null ? !store.encrypted : onDisk === "none"; + // `absent` is the only state where falling back to the configured write + // policy is honest — the first write will create the file in exactly that + // mode. `unreadable` must not: the encryption state was never established, + // and reporting the policy there would claim "Encrypted file" about a file + // `set` is about to refuse. + const unreadable = onDisk.state === "unreadable" ? onDisk.detail : undefined; + const plaintext = + onDisk.state === "absent" ? !store.encrypted : onDisk.state === "plaintext"; // Re-checked here rather than captured at selection, for the same reason // the encryption state is: the mode can change under a running process, // and this descriptor is what the browser renders as fact. The *read-only* @@ -346,12 +354,17 @@ async function describeFileStore( kind, reason, path: filePath, - plaintext, + // Omitted entirely when the envelope could not be read, so no consumer + // can read a default out of it. + ...(unreadable === undefined ? { plaintext } : {}), + ...(unreadable !== undefined ? { encryptionUnknown: unreadable } : {}), ...(perms.state === "loose" ? { looseMode: perms.mode } : {}), ...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}), // Only meaningful while the two disagree; omitted otherwise so the // payload does not carry a flag that says nothing. - ...(plaintext && store.encrypted ? { pendingEncryption: true } : {}), + ...(unreadable === undefined && plaintext && store.encrypted + ? { pendingEncryption: true } + : {}), durable: true, detail, }; @@ -481,7 +494,17 @@ export async function absorbFileSecretsIntoKeyring( for (const [account, value] of Object.entries(entries)) { const parsed = parseAccount(account); if (!parsed) continue; - const existing = await keyring.get(parsed.serverId, parsed.field); + // Strict: `get` answers `null` for an unreadable keychain as well as + // for a missing entry, and this branch *writes* on `null`. A transient + // read failure would therefore overwrite a newer keychain value with + // the older file copy — the exact inversion of the keychain-wins rule + // this migration is built on. A throw here aborts the hand-off and + // leaves the file in place, so the next run retries. + const existing = await secretStoreGetStrict( + keyring, + parsed.serverId, + parsed.field, + ); if (existing === null) { await keyring.set(parsed.serverId, parsed.field, value); } diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 5bce41e4..b59b4e34 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -320,6 +320,23 @@ export interface SecretStore { */ isDurable?(): Promise<boolean>; get(serverId: string, field: string): Promise<string | null>; + /** + * Like {@link get}, but **throws** when the store cannot be read instead + * of answering `null`. + * + * `get` is deliberately tolerant — an unreachable keychain and an absent + * entry are the same answer to a caller that just wants to render a form. + * That tolerance is wrong for exactly one caller: a migration that treats + * `null` as proof of absence and then *writes*. A transient read failure + * would look like "nothing there", and the write would overwrite a newer + * keychain value with an older file copy — silently inverting the + * keychain-wins rule the migration exists to honor. + * + * Optional, so existing implementations (and test doubles) keep + * compiling; {@link secretStoreGetStrict} falls back to `get` when it is + * absent, which is correct for stores whose reads cannot fail. + */ + getStrict?(serverId: string, field: string): Promise<string | null>; set(serverId: string, field: string, value: string): Promise<void>; /** No-op if no entry exists. */ delete(serverId: string, field: string): Promise<void>; @@ -370,6 +387,21 @@ export interface SecretStore { * one the first version of this contract actually handled. */ export class KeyringSecretStore implements SecretStore { + /** + * The intolerant read. Same lookup as {@link get}, minus the catch — a + * keychain that cannot answer must not be reported as an empty one to a + * caller that is about to write based on the answer. + */ + async getStrict(serverId: string, field: string): Promise<string | null> { + const keyring = await loadKeyring(); + if (!keyring.ok) throw new KeychainUnavailableError(keyring.err); + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); + return (await entry.getPassword()) ?? null; + } + async get(serverId: string, field: string): Promise<string | null> { try { const keyring = await loadKeyring(); @@ -466,6 +498,22 @@ export class KeyringSecretStore implements SecretStore { * other way would make every double silently non-durable and quietly * disable the migrations they exist to exercise. */ +/** + * Read `store` intolerantly: throw rather than answer `null` when the store + * itself cannot be read. Falls back to `get` for a store that does not + * distinguish the two, which is right for the in-memory and file stores — + * neither can fail in a way that masquerades as absence. + */ +export async function secretStoreGetStrict( + store: SecretStore, + serverId: string, + field: string, +): Promise<string | null> { + return store.getStrict + ? store.getStrict(serverId, field) + : store.get(serverId, field); +} + export async function secretStoreIsDurable( store: SecretStore, ): Promise<boolean> { diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index cfb28756..847d547f 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -83,6 +83,18 @@ export interface SecretStorageInfo { * mode 0600 as fact having verified nothing. */ permissionsUnknown?: string; + /** + * Set when the secrets file exists but its envelope could not be read — + * corrupt JSON, or a format written by a newer Inspector. Carries the + * reason. + * + * Mutually exclusive with a meaningful {@link plaintext}: the whole point + * is that the encryption state was never established, so neither "File + * (encrypted)" nor "File (unencrypted)" may be claimed. `set` will refuse + * such a file rather than overwrite it, so this is also a live warning + * about writes, not only a description. + */ + encryptionUnknown?: string; /** * True when secrets outlive the process. False only for `memory`, and * it is the *promise* being made rather than an implementation detail: @@ -106,6 +118,7 @@ export function secretStorageLabel(info: SecretStorageInfo): string { case "keyring": return "OS keychain"; case "file": + if (info.encryptionUnknown !== undefined) return "File (unreadable)"; return info.plaintext ? "File (unencrypted)" : "File (encrypted)"; case "memory": return "Memory (this session only)"; @@ -130,6 +143,9 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone { // Unverified is not the same as fine. The quiet tone here would read as a // confirmation we never made. if (info.permissionsUnknown !== undefined) return "warn"; + // A file we cannot read is one we cannot write either — a warning about + // the next save, not just about the past. + if (info.encryptionUnknown !== undefined) return "warn"; return "neutral"; } @@ -149,7 +165,13 @@ export function secretStorageCaveat( // property of a file only its owner can open. if (info.looseMode !== undefined) { const mode = info.looseMode.toString(8).padStart(4, "0"); - return `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`; + // The consequence differs by encryption, and overstating it is its own + // kind of wrong: a reader of an *encrypted* file gets ciphertext, not + // secrets. Saying otherwise trains people to discount the warning on the + // occasion it is literally true. + return info.plaintext === false + ? `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and the passphrase is then the only thing protecting its contents.` + : `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`; } // Ordered with the permission problems, above encryption, for the same // reason: not knowing whether others can read the file is a question about @@ -157,6 +179,11 @@ export function secretStorageCaveat( if (info.permissionsUnknown !== undefined) { return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`; } + // An unreadable envelope outranks the encryption caveats below, which all + // presuppose we know which mode the file is in. + if (info.encryptionUnknown !== undefined) { + return `The secrets file could not be read (${info.encryptionUnknown}). Saving a secret will fail rather than overwrite it.`; + } if (info.plaintext) { return info.pendingEncryption ? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved." diff --git a/core/react/useInitialConfig.ts b/core/react/useInitialConfig.ts index 80f8d9b3..475e39f3 100644 --- a/core/react/useInitialConfig.ts +++ b/core/react/useInitialConfig.ts @@ -97,7 +97,16 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { if (typeof v.durable !== "boolean") return undefined; if (v.kind === "file") { if (typeof v.path !== "string" || v.path === "") return undefined; - if (typeof v.plaintext !== "boolean") return undefined; + // Exactly one of the two must be present: either we know the encryption + // state, or we know we could not read it. A descriptor carrying neither + // (or both) was not built by a backend we understand, and the footer + // would have to invent the missing half. + const knowsEncryption = typeof v.plaintext === "boolean"; + const knowsItCannotRead = typeof v.encryptionUnknown === "string"; + if (knowsEncryption === knowsItCannotRead) return undefined; + if (v.encryptionUnknown !== undefined && !knowsItCannotRead) { + return undefined; + } // Optional, so absent is fine — but a *present* value must be a real // boolean. `pendingEncryption: "false"` is a truthy string, and the // footer would read it as "already re-encrypting" and print advice that @@ -125,7 +134,8 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined { v.plaintext !== undefined || v.pendingEncryption !== undefined || v.looseMode !== undefined || - v.permissionsUnknown !== undefined + v.permissionsUnknown !== undefined || + v.encryptionUnknown !== undefined ) { return undefined; } From 6efc65d2afd68e6f49b25cdc50592c86270da0ee Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 14:57:55 -0400 Subject: [PATCH 242/941] fix(auth): address Copilot review round 7 (five of six findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five taken; one rejected with evidence, below. - **The keychain hand-off ran outside the file lock.** Read → copy → delete has to be one transaction: another Inspector completing a `set` between the read and the delete had its brand-new secret removed without ever being copied — a write that reported success and then vanished, which is the loss the lock exists to prevent, reached through the migration meant to preserve things. The whole sequence now runs under the same cross-process lock every mutation takes. - **An unmigratable entry was skipped and the file deleted anyway.** A key that is not `serverId:field` cannot be addressed through the store API, so it cannot be copied — which makes the hand-off incomplete by definition, and the complete-success rule then forbids removing the source. It was discarding that value permanently in order to tidy up a migration that had failed. The copyable entries still go across; the file stays, and says why. - **`readOnDiskEncryption` accepted any format version.** A version-2 file was reported as a usable plaintext or encrypted store even though `readMap` refuses it and every save fails. Checked before the mode now, matching `readMap`. - **The loose-mode warning at selection was encryption-blind**, claiming a reader could read the secrets of a file that is ciphertext, and it duplicated the caveat printed moments later by `warnAboutSecretStorage`. Selection now only repairs; the descriptor path does the reporting, because it is the one that knows whether the file is encrypted. - **The footer's tooltip offered to copy the path without showing it.** The band stopped printing it and the accessible name carries it for screen-reader users, which left a sighted user unable to see where a custom destination points without pasting it somewhere else. **Not taken: "`writeStoreFile` is not atomic."** It is. The finding reads `store-io.ts` line 84 as a direct write, but `writeFile` there is imported from the `atomically` package, not `node:fs`. Verified rather than argued: writing over an existing file changes its inode (so a rename happened) and leaves mode `0600`. I had started implementing the suggested temp+rename before checking the import, which would have replaced a tested library with a hand-rolled duplicate. That check corrected something of mine, though: `tightenSecretFilePermissions` justified itself with "a rename over a 0644 file keeps the loose mode forever", which is false — the temp carries `mode: 0o600` and the rename replaces the inode, so every *write* re-establishes it. The function is still needed for the case it actually addresses: a file that exists and is never written. Comment corrected to say so. Full `npm run ci` green — 5,753 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../SecretStorageFooter.test.tsx | 24 ++++++++ .../SecretStorageFooter.tsx | 7 ++- .../auth/node/file-secret-store.test.ts | 16 +++++ .../auth/node/secret-store-selection.test.ts | 50 ++++++++++++++-- core/auth/node/file-secret-store.ts | 21 +++++-- core/auth/node/secret-store-selection.ts | 59 ++++++++++++++++--- 6 files changed, 160 insertions(+), 17 deletions(-) diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index a639d91f..74cf979f 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -173,4 +173,28 @@ describe("SecretStorageFooter", () => { expect(band).toHaveTextContent("Mode 0644 — not owner-only."); expect(band).toHaveAttribute("data-tone", "warn"); }); + + it("puts the path in the tooltip, not just an offer to copy it", async () => { + // The band no longer prints the path and the accessible name carries it + // for screen-reader users, which left a sighted user unable to see where + // a custom destination points without copying it out somewhere else. + renderWithMantine( + <SecretStorageFooter + info={{ + kind: "file", + reason: "fallback", + durable: true, + plaintext: false, + path: "/custom/place/secrets.json", + }} + />, + ); + // Hover the control the tooltip is attached to, not the band around it. + await userEvent.hover( + screen.getByRole("button", { name: /Copy secrets file path/ }), + ); + const tip = await screen.findByRole("tooltip"); + expect(tip).toHaveTextContent("/custom/place/secrets.json"); + expect(tip).toHaveTextContent("Click to copy."); + }); }); diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index 8df1234e..e60679be 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -206,7 +206,12 @@ function footerTooltip(info: SecretStorageInfo): string | undefined { `The file's permissions could not be read (${info.permissionsUnknown}), so it is not known whether others can read it.`, ); } - parts.push("Click to copy file path"); + // The path itself, not just an offer to copy it. The band no longer prints + // it, and the accessible name carries it for screen-reader users — which + // left a sighted user unable to see *where* a custom destination points + // without copying it out to somewhere else first. + parts.push(info.path); + parts.push("Click to copy."); return parts.join(" "); } diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 69f6df4b..39e481c4 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -623,6 +623,22 @@ describe("readOnDiskEncryption", () => { ).toMatch(/unsupported envelope/); }); + it("calls a newer format version unreadable rather than usable", async () => { + // `readMap` refuses such a file, so describing it as a working plaintext + // or encrypted store would present it as fine while every save fails. + await fs.writeFile( + filePath(), + JSON.stringify({ version: 2, encryption: "none", secrets: {} }), + "utf-8", + ); + const store = new FileSecretStore({ filePath: filePath() }); + const state = await store.readOnDiskEncryption(); + expect(state.state).toBe("unreadable"); + expect(state.state === "unreadable" ? state.detail : "").toMatch( + /format version 2/, + ); + }); + it("needs no passphrase — it reads the envelope, never the payload", async () => { // A store whose key is wrong still has to be describable in the UI. const writer = new FileSecretStore({ diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index a18a1a8f..35906b53 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -704,22 +704,64 @@ describe("absorbFileSecretsIntoKeyring", () => { expect(warn).not.toHaveBeenCalled(); }); - it("skips an entry whose account key is malformed", async () => { + it("copies what it can but keeps the file when an entry is unmigratable", async () => { // A hand-edited file can carry a key that is not `serverId:field`. It - // cannot be addressed through the store API, so it is passed over rather - // than guessed at — but it must not abort the entries around it. + // cannot be addressed through the store API, so it cannot be copied — + // which makes the hand-off incomplete, and an incomplete hand-off must + // not delete its source. Removing the file anyway would discard that + // value permanently in order to tidy up a migration that had failed. const filePath = await seedFile({ "no-separator": "orphan", "srv:oauthClientSecret": "from-file", }); process.env.MCP_INSPECTOR_SECRET_FILE = filePath; - vi.spyOn(console, "warn").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const mod = await loadWithProbe(true); const keyring = new InMemorySecretStore(); await mod.absorbFileSecretsIntoKeyring(keyring); + // The migratable entry is across... expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file"); + // ...and the orphan still exists on disk rather than being dropped. + expect(existsSync(filePath)).toBe(true); + expect(JSON.parse(await fs.readFile(filePath, "utf-8"))).toMatchObject({ + secrets: { "no-separator": "orphan" }, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("left in place so nothing is lost"), + ); + }); + + it("holds the file lock for the whole hand-off", async () => { + // Read → copy → delete must be one transaction. Another Inspector + // completing a `set` between the read and the delete would have its + // brand-new secret removed without ever being copied — a write that + // reported success and then vanished. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + + const { acquireLock } = + await import("@inspector/core/auth/node/file-secret-store.js"); + // Hold the lock as the "other process" would. + const release = await acquireLock(filePath, { heartbeatMs: 20 }); + let finished = false; + const handOff = mod + .absorbFileSecretsIntoKeyring(new InMemorySecretStore()) + .then(() => { + finished = true; + }); + + await new Promise((r) => setTimeout(r, 120)); + // Blocked on us, not steaming ahead and deleting the file. + expect(finished).toBe(false); + expect(existsSync(filePath)).toBe(true); + + await release(); + await handOff; + expect(finished).toBe(true); }); it("runs the hand-off for an explicitly configured keyring too", async () => { diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index 35bf92bc..a18f5b26 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -589,6 +589,16 @@ export class FileSecretStore implements SecretStore { if (raw === null) return { state: "absent" }; try { const parsed = JSON.parse(raw) as SecretFile; + // Version before mode, matching `readMap`. A version this build does + // not understand is refused there, so presenting such a file as a + // usable "plaintext" or "encrypted" store would describe it as working + // while every save fails. + if (parsed.version !== FORMAT_VERSION) { + return { + state: "unreadable", + detail: `format version ${String(parsed.version)}, expected ${FORMAT_VERSION}`, + }; + } if (parsed.encryption === "none") return { state: "plaintext" }; if (parsed.encryption === CIPHER) return { state: "encrypted" }; return { @@ -860,11 +870,12 @@ export class FileSecretStore implements SecretStore { /** * Best-effort permission repair for a pre-existing secrets file. * - * `writeStoreFile` passes `mode: 0o600`, but a mode argument only applies - * when the file is *created* — an atomic rename over a file someone made - * `0644` by hand (or restored from a backup, or copied out of an image) - * keeps the loose mode forever. Called once at store selection so the - * guarantee the docs make is the guarantee on disk. + * `writeStoreFile` writes through `atomically`, which creates a temp file + * with `mode: 0o600` and renames it over the destination — so every *write* + * re-establishes the mode regardless of what the old file had. What it + * cannot fix is a file that is never written: one made `0644` by hand, + * restored from a backup, or copied out of an image is read at that mode + * indefinitely. Called once at store selection to close exactly that gap. * * Failures are swallowed: a file we cannot chmod is one we probably cannot * write either, and `set` is where that gets reported with context. diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index 536e0761..6802a89b 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -49,6 +49,7 @@ import { secretStorageSummary, } from "../secret-storage-info.js"; import { + acquireLock, FileSecretStore, readSecretFilePermissions, tightenSecretFilePermissions, @@ -302,12 +303,12 @@ async function buildStore( // *verify* the result. The UI and the README both state the file is 0600, // so a file we could not tighten (owned by another user, on a read-only // mount) has to be reported rather than quietly described as protected. - const perms = await tightenSecretFilePermissions(filePath); - if (perms.state === "loose") { - console.warn( - `[mcp-inspector] The secrets file at ${filePath} is mode ${perms.mode.toString(8).padStart(4, "0")}, not 0600, and could not be tightened. Anyone who can read it can read the secrets in it.`, - ); - } + // Repair only — the *reporting* belongs to the descriptor path, which + // knows whether the file is encrypted and so what the consequence + // actually is. This used to warn here too, in wording that claimed a + // reader could read the secrets even when the file was ciphertext, and + // duplicated the caveat `warnAboutSecretStorage` prints moments later. + await tightenSecretFilePermissions(filePath); return { store, info: await describeFileStore(store, filePath, kind, reason, detail), @@ -479,6 +480,34 @@ export async function absorbFileSecretsIntoKeyring( if (!fsSync.existsSync(filePath)) return; const file = new FileSecretStore({ filePath }); + // The whole read → copy → delete sequence runs under the same cross-process + // lock every mutation takes. Without it, another Inspector completing a + // `set` between our read and our delete has its brand-new secret removed + // and never copied — a write that reported success and then vanished, + // which is precisely the loss the lock exists to prevent, arrived at + // through the migration meant to preserve things. + let release: (() => Promise<void>) | undefined; + try { + release = await acquireLock(filePath); + } catch (err) { + console.warn( + `\n[mcp-inspector] Could not lock the secrets file at ${filePath} to move it into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + try { + await handOffUnderLock(file, filePath, keyring); + } finally { + await release(); + } +} + +/** The hand-off body. Split out so the lock's release is unmissable. */ +async function handOffUnderLock( + file: FileSecretStore, + filePath: string, + keyring: SecretStore, +): Promise<void> { let entries: Record<string, string> | null; try { entries = await file.readAll(); @@ -490,10 +519,19 @@ export async function absorbFileSecretsIntoKeyring( } if (!entries || Object.keys(entries).length === 0) return; + // A key that is not `serverId:field` cannot be addressed through the store + // API, so it cannot be copied — which makes the hand-off incomplete by + // definition, and an incomplete hand-off must not delete its source. It + // used to be skipped and the file removed anyway, discarding the value + // permanently to tidy up a file we had failed to fully migrate. + let complete = true; try { for (const [account, value] of Object.entries(entries)) { const parsed = parseAccount(account); - if (!parsed) continue; + if (!parsed) { + complete = false; + continue; + } // Strict: `get` answers `null` for an unreadable keychain as well as // for a missing entry, and this branch *writes* on `null`. A transient // read failure would therefore overwrite a newer keychain value with @@ -516,6 +554,13 @@ export async function absorbFileSecretsIntoKeyring( return; } + if (!complete) { + console.warn( + `\n[mcp-inspector] Secrets from ${filePath} have been copied into the OS keychain, but at least one entry could not be read (its key is not in \`serverId:field\` form). The file has been left in place so nothing is lost.`, + ); + return; + } + try { await fs.rm(filePath, { force: true }); console.warn( From 5f4a6ea09b27416971926593d092d2d21905189a Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 17:12:29 -0400 Subject: [PATCH 243/941] test(auth): pin the loose-mode test to the mechanism that survived MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No behavior change. Round 7 removed the loose-mode `console.warn` from selection — it was encryption-blind and duplicated the caveat printed moments later. This test kept asserting `warn` was called with "0644" and kept passing, because the caveat also contains the mode. So it was green while describing a code path that no longer exists: named "warns once at selection", commented as if the selection warning were the thing under test, and pinned to a substring loose enough that either line would satisfy it. Asserts the caveat's full sentence now, so it is fastened to the surviving mechanism rather than to whichever line happens to mention the mode. Also records what it took to reach this state at all, since it is not obvious and I had it wrong: as the file's owner you *cannot* stage a loose-mode descriptor, because `tightenSecretFilePermissions` repairs a 0644 file to 0600 during selection and the report is then correctly "owner-only". It is only real for a file owned by another user or on a read-only mount, which is why the chmod is mocked — standing in for a privilege a test cannot drop. Found by @mcp-inspector-v2-f1, who tried to screenshot the state for the PR body and got "Owner-only permissions." instead. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../auth/node/secret-store-selection.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 35906b53..f7a6fe05 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -882,10 +882,19 @@ describe("the descriptor reports a mode it could not verify", () => { }); describe("the descriptor reports a mode it could not tighten", () => { - it("carries looseMode into the descriptor and warns once at selection", async () => { - // The warning alone was not enough: the caveat under the secret field - // states the mode as fact, and on this box that statement was untrue - // while the browser had no way to know. + it("carries looseMode into the descriptor, and the caveat states the mode", async () => { + // Reaching this state at all requires the chmod to *fail*: as the file's + // owner you cannot stage it, because `tightenSecretFilePermissions` + // repairs a 0644 file to 0600 during selection and the descriptor then + // correctly reports owner-only. It is only real for a file owned by + // another user or on a read-only mount — hence the mocked chmod, which + // is standing in for a privilege we cannot drop inside a test. + // + // The warning is asserted through the *caveat* path deliberately. + // Selection used to warn here too, and that line was removed in round 7 + // for being encryption-blind and duplicating this one — so asserting the + // caveat text is what keeps this test pinned to the surviving mechanism + // rather than passing on whichever line happens to mention the mode. const filePath = path.join(tmpDir, "secrets.json"); process.env.MCP_INSPECTOR_SECRET_FILE = filePath; process.env.MCP_INSPECTOR_SECRET_STORE = "file"; @@ -925,7 +934,13 @@ describe("the descriptor reports a mode it could not tighten", () => { await import("@inspector/core/auth/node/secret-store-selection.js"); const info = await mod.getSecretStorageInfo(); expect(info.looseMode).toBe(0o644); - expect(warn).toHaveBeenCalledWith(expect.stringContaining("0644")); + // The plaintext wording, in full: this file is unencrypted, so a + // reader really does get the secrets. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "mode 0644, not 0600, and could not be tightened — anyone who can read it can read the secrets in it", + ), + ); } finally { vi.doUnmock("node:fs/promises"); vi.doUnmock("@inspector/core/auth/node/secret-store.js"); From 37faf1be8835f17e69646a59d6ce05a4a5d1d1ba Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 17:41:56 -0400 Subject: [PATCH 244/941] fix(auth): address Copilot review round 8 (four inline findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lock finding from this round is deliberately not addressed here — see below. - **My round-6 strict-read fix was inert in production.** `DeferredSecretStore` is what `defaultSecretStore()` returns, and it did not forward `getStrict` — so `secretStoreGetStrict` fell through to the tolerant `get` for every real caller, and the strictness existed only in tests that injected a concrete store. Forwarded now, along with `getMany`. - **The same tolerant-read bug remained in both pre-existing migrations.** I added `getStrict` for the new hand-off in round 6 and never applied it to the mcp.json and client.json migrations, which have the identical shape: decide from `get`, then write on `null`. A transient keychain read failure therefore let an older plaintext value overwrite a newer keychain one — and the disk copy was then stripped, so the newer value was gone. Both use the strict read now; the throw is a `SecretStoreUnavailableError`, which both call sites already handle as "abandon the migration, keep the file". - **The permission check made a false statement on Windows.** `stat` returns synthetic bits there, and interpreting them yields a confident falsehood either way — "0666 — not owner-only" about a file the ACL may restrict, or "owner-only" about one it does not. The ACL is the real answer and this does not inspect it, so Windows now reports `unknown`. - **Rehydration cost one full decryption per field.** `get` reads and decrypts the entire file, and both rehydration paths ask field by field, serialized behind the store's own queue. Measured on this machine, scrypt at N=16384 is ~23ms per derivation, so a 20-secret encrypted catalog spent **~450ms of pure key derivation on every `GET /api/servers`** — a visible stall, not a micro-optimization. Added an optional `getMany` seam: `FileSecretStore` decrypts once per server, everything else falls back to the parallel per-field `get` that already suited it. A test counts derivations directly: 1 for a three-field `getMany`, 3 for the three `get`s it replaced. Also broadens a log message that still said "Keychain unavailable" for a store that may not be a keychain. Full `npm run ci` green — 5,762 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../test/core/client/node-persistence.test.ts | 29 +++++ .../auth/node/file-secret-store.test.ts | 122 ++++++++++++++++++ .../auth/node/secret-store-selection.test.ts | 39 ++++++ .../mcp/remote/server-extra-coverage.test.ts | 4 +- .../mcp/remote/servers-route.test.ts | 31 +++++ core/auth/node/file-secret-store.ts | 43 ++++++ core/auth/node/secret-store-selection.ts | 16 +++ core/auth/node/secret-store.ts | 37 ++++++ core/client/node-persistence.ts | 9 +- core/mcp/node/server-secrets.ts | 18 ++- core/mcp/remote/node/server.ts | 27 ++-- 11 files changed, 351 insertions(+), 24 deletions(-) diff --git a/clients/web/src/test/core/client/node-persistence.test.ts b/clients/web/src/test/core/client/node-persistence.test.ts index 7fadb821..8d4233f8 100644 --- a/clients/web/src/test/core/client/node-persistence.test.ts +++ b/clients/web/src/test/core/client/node-persistence.test.ts @@ -63,6 +63,35 @@ describe("client node-persistence", () => { ).toBe("plain"); }); + it("aborts rather than overwriting when the keychain read fails", async () => { + // The keychain-wins lookup used the tolerant `get`, which maps an + // unreadable store to `null` — and the branch below writes on `null`. So + // a transient read failure let the older client.json copy overwrite a + // newer stored secret, and the disk copy was then stripped. + const filePath = await makeTmpFile( + JSON.stringify(configWithPlaintextSecret), + ); + let wrote = false; + const flaky: SecretStore = { + get: async () => null, + getStrict: async () => { + throw new KeychainUnavailableError(new Error("temporarily down")); + }, + set: async () => { + wrote = true; + }, + delete: async () => {}, + deleteAllForServer: async () => {}, + }; + + const loaded = await readClientConfigStore(filePath, flaky); + + expect(wrote).toBe(false); + // The plaintext survives for the next attempt. + expect(readFileSync(filePath, "utf-8")).toContain("plain"); + expect(loaded.enterpriseManagedAuth?.idp.clientSecret).toBe("plain"); + }); + it("keeps the plaintext on disk when the store is session-scoped", async () => { // The container fallback. Migrating here would delete a secret that // survives restarts and keep only a copy that dies with the process — diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 39e481c4..8922e4cc 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -452,6 +452,50 @@ describe("tightenSecretFilePermissions", () => { expect((await fs.stat(filePath())).mode & 0o777).toBe(0o600); }); + it("declines to interpret Windows' synthetic mode bits", async () => { + // Windows does not model POSIX modes. `stat` still returns bits, and + // reading them as a permission statement is a confident falsehood either + // way — "0666 — not owner-only" about a file the ACL may restrict, or + // "owner-only" about one it does not. The ACL is the real answer and this + // does not inspect it. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("alpha", "env:A", "1"); + const real = process.platform; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + try { + expect(await readSecretFilePermissions(filePath())).toEqual({ + state: "unknown", + detail: "not checked on Windows", + }); + } finally { + Object.defineProperty(process, "platform", { + value: real, + configurable: true, + }); + } + }); + + it("still reports absent on Windows when there is no file", async () => { + const real = process.platform; + Object.defineProperty(process, "platform", { + value: "win32", + configurable: true, + }); + try { + expect( + await readSecretFilePermissions(path.join(tmpDir, "nope.json")), + ).toEqual({ state: "absent" }); + } finally { + Object.defineProperty(process, "platform", { + value: real, + configurable: true, + }); + } + }); + it("reports unknown, not absent, when an existing file cannot be inspected", async () => { // Collapsing every stat failure into "absent" is what made it dangerous: // the descriptor then omits the warning and the footer states mode 0600 @@ -700,6 +744,84 @@ async function writeEncryptedFixtureWithPayload( ); } +describe("getMany", () => { + it("returns a server's fields from a single read", async () => { + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + await store.set("srv", SECRET_FIELD_OAUTH_CLIENT_SECRET, "s1"); + await store.set("srv", "env:TOKEN", "s2"); + await store.set("other", "env:TOKEN", "nope"); + + expect( + await store.getMany("srv", [ + SECRET_FIELD_OAUTH_CLIENT_SECRET, + "env:TOKEN", + "env:ABSENT", + ]), + ).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "s1", "env:TOKEN": "s2" }); + }); + + it("derives the key once for the whole set, not once per field", async () => { + // The reason the seam exists: `get` reads and decrypts the *entire* file, + // so rehydrating field-by-field cost one scrypt derivation per field, + // serialized behind this store's queue. Counting derivations is the + // measurement that matters — scrypt is the expensive part, deliberately + // so. + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + await store.set("srv", "env:A", "1"); + await store.set("srv", "env:B", "2"); + await store.set("srv", "env:C", "3"); + + let derivations = 0; + vi.resetModules(); + vi.doMock("node:crypto", async () => { + const actual = + await vi.importActual<typeof import("node:crypto")>("node:crypto"); + return { + ...actual, + default: actual, + scrypt: (...args: Parameters<typeof actual.scrypt>) => { + derivations += 1; + return actual.scrypt(...args); + }, + }; + }); + try { + const mod = + await import("@inspector/core/auth/node/file-secret-store.js"); + const fresh = new mod.FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + + await fresh.getMany("srv", ["env:A", "env:B", "env:C"]); + expect(derivations).toBe(1); + + // And the shape it replaced, for contrast: three fields, three + // derivations. + derivations = 0; + await fresh.get("srv", "env:A"); + await fresh.get("srv", "env:B"); + await fresh.get("srv", "env:C"); + expect(derivations).toBe(3); + } finally { + vi.doUnmock("node:crypto"); + vi.resetModules(); + } + }); + + it("is tolerant like get: an unreadable store yields no fields", async () => { + await fs.writeFile(filePath(), "{ not json", "utf-8"); + const store = new FileSecretStore({ filePath: filePath() }); + expect(await store.getMany("srv", ["env:A"])).toEqual({}); + }); +}); + describe("payload shape validation", () => { // GCM proves who wrote the bytes, not what shape they parse to, and the // plaintext branch proves nothing at all — so neither may be cast. diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index f7a6fe05..4767aab3 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -545,6 +545,45 @@ describe("defaultSecretFilePath", () => { }); }); +describe("DeferredSecretStore forwards the optional seams", () => { + // This is what `defaultSecretStore()` returns, so a seam it drops is a + // seam that does not exist in production — however well the tests that + // inject a concrete store behave. + it("forwards getStrict rather than degrading to the tolerant get", async () => { + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + const store = mod.defaultSecretStore(); + expect(typeof store.getStrict).toBe("function"); + + const { secretStoreGetStrict } = + await import("@inspector/core/auth/node/secret-store.js"); + // The file store's strict read is its ordinary read; the point here is + // that the call reaches the target at all. + expect(await secretStoreGetStrict(store, "srv", "env:A")).toBe(null); + await store.set("srv", "env:A", "1"); + expect(await secretStoreGetStrict(store, "srv", "env:A")).toBe("1"); + }); + + it("forwards getMany", async () => { + process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "secrets.json"); + process.env.MCP_INSPECTOR_SECRET_STORE = "file"; + vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(false); + const store = mod.defaultSecretStore(); + await store.set("srv", "env:A", "1"); + await store.set("srv", "env:B", "2"); + + const { secretStoreGetMany } = + await import("@inspector/core/auth/node/secret-store.js"); + expect(await secretStoreGetMany(store, "srv", ["env:A", "env:B"])).toEqual({ + "env:A": "1", + "env:B": "2", + }); + }); +}); + describe("absorbFileSecretsIntoKeyring", () => { // The transition: a box without libsecret falls back to a file, the user // saves secrets there, then installs libsecret. Without a hand-off the next diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 427800a6..e6140467 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -991,7 +991,9 @@ describe("server.ts supplemental coverage", () => { const warned = cap.records.filter( (r) => r.level === "warn" && - JSON.stringify(r.args).includes("Keychain unavailable"), + // Wording broadened with the store: a file-backed store fails for + // reasons that have nothing to do with a keychain. + JSON.stringify(r.args).includes("Secret store unavailable"), ); expect(warned.length).toBeGreaterThan(0); } finally { diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index a5f6f013..6062fd7d 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -2674,6 +2674,37 @@ describe("plaintext migration against a session-scoped store (#1950)", () => { expect(readFileSync(configPath, "utf-8")).toBe(before); }); + it("aborts the migration when the keychain read fails, preserving mcp.json", async () => { + // The keychain-wins lookup used the tolerant `get`, so an unreadable + // keychain read as "nothing there" — and the write that followed would + // put the older plaintext over a newer keychain value before the disk + // copy was stripped. + let wrote = false; + const flaky: SecretStore = { + get: async () => null, + getStrict: async () => { + throw new KeychainUnavailableError(new Error("temporarily down")); + }, + set: async () => { + wrote = true; + }, + delete: async () => {}, + deleteAllForServer: async () => {}, + }; + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + mcpConfigPath: configPath, + initialConfig: { defaultEnvironment: {} }, + secretStore: flaky, + }); + const before = readFileSync(configPath, "utf-8"); + + const res = await app.request(new Request("http://test/api/servers")); + expect(res.status).toBe(200); + expect(wrote).toBe(false); + expect(readFileSync(configPath, "utf-8")).toBe(before); + }); + it("still strips it against a durable store, so the guard is the difference", async () => { // The control: same config, same request, a store that outlives the // process — and the migration does what it always did. diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index a18f5b26..c4262bff 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -792,6 +792,34 @@ export class FileSecretStore implements SecretStore { return run; } + /** + * One read and one derivation for a whole server's fields. + * + * The reason this exists: `get` reads and decrypts the entire file, and + * the rehydration callers ask field by field — so an encrypted catalog + * paid N scrypt derivations per server, serialized behind this store's own + * queue, every time the server list was loaded. Tolerant like `get`: an + * unreadable store yields no fields rather than throwing. + */ + async getMany( + serverId: string, + fields: string[], + ): Promise<Record<string, string>> { + let map: Record<string, string> | null; + try { + map = await this.serialize(() => this.readMap()); + } catch { + return {}; + } + if (!map) return {}; + const out: Record<string, string> = {}; + for (const field of fields) { + const value = map[buildAccount(serverId, field)]; + if (value !== undefined) out[field] = value; + } + return out; + } + async get(serverId: string, field: string): Promise<string | null> { try { const map = await this.serialize(() => this.readMap()); @@ -870,6 +898,10 @@ export class FileSecretStore implements SecretStore { /** * Best-effort permission repair for a pre-existing secrets file. * + * A no-op in effect on Windows, where `chmod` cannot express "owner only" + * — {@link readSecretFilePermissions} reports `unknown` there rather than + * interpreting the synthetic mode bits. + * * `writeStoreFile` writes through `atomically`, which creates a temp file * with `mode: 0o600` and renames it over the destination — so every *write* * re-establishes the mode regardless of what the old file had. What it @@ -911,6 +943,17 @@ export async function readSecretFilePermissions( filePath: string, ): Promise<SecretFilePermissionState> { try { + // Windows does not model POSIX modes. `stat` still returns *something* + // — synthetic bits derived from the read-only attribute — and reading + // them as a permission statement produces a confident falsehood either + // way: "0666 — not owner-only" about a file the ACL may well restrict, + // or "owner-only" about one it does not. The real answer lives in the + // ACL, which this does not inspect, so the honest report is that we did + // not check. + if (process.platform === "win32") { + await fs.stat(filePath); // still distinguishes absent from present + return { state: "unknown", detail: "not checked on Windows" }; + } const mode = (await fs.stat(filePath)).mode & 0o777; if (mode === 0o600) return { state: "ok" }; return { state: "loose", mode }; diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index 6802a89b..ed05cf3b 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -58,6 +58,7 @@ import { KeyringSecretStore, parseAccount, probeKeyringAvailable, + secretStoreGetMany, secretStoreGetStrict, secretStoreIsDurable, SessionSecretStore, @@ -615,6 +616,21 @@ class DeferredSecretStore implements SecretStore { async isDurable(): Promise<boolean> { return secretStoreIsDurable(await this.target()); } + /** + * Forwarded, and its absence was a real defect rather than an omission: + * this is the store production actually uses, so a `secretStoreGetStrict` + * call against it fell through to the tolerant `get` and the strictness + * existed only in tests that injected a concrete store. + */ + async getStrict(serverId: string, field: string): Promise<string | null> { + return secretStoreGetStrict(await this.target(), serverId, field); + } + async getMany( + serverId: string, + fields: string[], + ): Promise<Record<string, string>> { + return secretStoreGetMany(await this.target(), serverId, fields); + } async get(serverId: string, field: string): Promise<string | null> { return (await this.target()).get(serverId, field); } diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index b59b4e34..3ccf1c73 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -337,6 +337,20 @@ export interface SecretStore { * absent, which is correct for stores whose reads cannot fail. */ getStrict?(serverId: string, field: string): Promise<string | null>; + /** + * Read several of one server's fields in a single pass. + * + * Optional, and purely a performance seam: {@link secretStoreGetMany} + * falls back to a parallel `get` per field, which is what the keychain + * wants anyway (independent native round-trips). + * + * It exists for `FileSecretStore`, where the shape is inverted — every + * `get` reads and decrypts the *whole* file, so N fields cost N scrypt + * derivations, serialized behind the store's own queue. The rehydration + * callers ask for one server's fields at a time, so a per-server bulk read + * turns that back into one. + */ + getMany?(serverId: string, fields: string[]): Promise<Record<string, string>>; set(serverId: string, field: string, value: string): Promise<void>; /** No-op if no entry exists. */ delete(serverId: string, field: string): Promise<void>; @@ -514,6 +528,29 @@ export async function secretStoreGetStrict( : store.get(serverId, field); } +/** + * Read several fields of one server, using the store's bulk path when it has + * one. Returns only the fields that are present, matching the callers that + * merge the result into a stored config. + */ +export async function secretStoreGetMany( + store: SecretStore, + serverId: string, + fields: string[], +): Promise<Record<string, string>> { + if (store.getMany) return store.getMany(serverId, fields); + const entries = await Promise.all( + fields.map( + async (field) => [field, await store.get(serverId, field)] as const, + ), + ); + const out: Record<string, string> = {}; + for (const [field, value] of entries) { + if (value !== null) out[field] = value; + } + return out; +} + export async function secretStoreIsDurable( store: SecretStore, ): Promise<boolean> { diff --git a/core/client/node-persistence.ts b/core/client/node-persistence.ts index f1e83fcb..6dbaf0cc 100644 --- a/core/client/node-persistence.ts +++ b/core/client/node-persistence.ts @@ -3,6 +3,7 @@ */ import { + secretStoreGetStrict, secretStoreIsDurable, SecretStoreUnavailableError, type SecretStore, @@ -45,7 +46,13 @@ async function migrateClientPlaintextSecret( if (!value) return config; try { - const existing = await secretStore.get( + // Strict: `get` answers `null` for an unreadable store as well as a + // missing entry, and the branch below *writes* on `null` — so a + // transient failure would overwrite a newer stored secret with the older + // `client.json` copy, inverting keychain-wins. A throw is caught below + // and leaves the plaintext file untouched for the next attempt. + const existing = await secretStoreGetStrict( + secretStore, CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET, ); diff --git a/core/mcp/node/server-secrets.ts b/core/mcp/node/server-secrets.ts index aae0bd14..a35e558c 100644 --- a/core/mcp/node/server-secrets.ts +++ b/core/mcp/node/server-secrets.ts @@ -1,4 +1,7 @@ -import type { SecretStore } from "../../auth/node/secret-store.js"; +import { + secretStoreGetMany, + type SecretStore, +} from "../../auth/node/secret-store.js"; import { expectedSecretFields, mergeSecretsIntoStored } from "../serverList.js"; import type { MCPConfig } from "../types.js"; @@ -15,15 +18,10 @@ export async function rehydrateMcpConfigFromKeychain( await Promise.all( Object.entries(config.mcpServers).map(async ([id, stored]) => { const fields = expectedSecretFields(stored); - const secrets: Record<string, string> = {}; - await Promise.all( - fields.map(async (field) => { - const value = await secretStore.get(id, field); - if (value !== null) { - secrets[field] = value; - } - }), - ); + // One pass per server, not one per field. For the keychain this is the + // same parallel round-trips as before; for the file store it collapses + // N reads-and-decrypts into one (see `secretStoreGetMany`). + const secrets = await secretStoreGetMany(secretStore, id, fields); out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets); }), ); diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 90241927..cf2f58cf 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -65,6 +65,8 @@ import { RemoteSession } from "./remote-session.js"; import { createRemoteAuthProvider } from "./tokenAuthProvider.js"; import { API_SERVER_ENV_VARS } from "../constants.js"; import { + secretStoreGetMany, + secretStoreGetStrict, secretStoreIsDurable, SecretStoreUnavailableError, type SecretStore, @@ -1899,16 +1901,11 @@ export function createRemoteApp( id: string, fields: string[], ): Promise<Record<string, string>> => { - const values = await Promise.all( - fields.map( - async (field) => [field, await secretStore.get(id, field)] as const, - ), - ); - const out: Record<string, string> = {}; - for (const [field, v] of values) { - if (v !== null) out[field] = v; - } - return out; + // One pass per server rather than one per field — see + // `secretStoreGetMany`. Unchanged for the keychain (still independent + // parallel round-trips); for the file store it is the difference between + // one decryption and one per field. + return secretStoreGetMany(secretStore, id, fields); }; // Cheap structural check used by the GET handler to decide whether @@ -1954,7 +1951,13 @@ export function createRemoteApp( continue; } for (const [field, value] of Object.entries(secrets)) { - const existing = await secretStore.get(id, field); + // Strict: `get` maps an unreadable store to `null`, and this + // branch *writes* on `null` — so a transient keychain read + // failure would let the older plaintext value overwrite a newer + // keychain one, and the disk copy would then be stripped. A throw + // is a `SecretStoreUnavailableError`, which the catch below turns + // into "abandon the migration and keep mcp.json as it is". + const existing = await secretStoreGetStrict(secretStore, id, field); if (existing === null) { await secretStore.set(id, field, value); } @@ -1982,7 +1985,7 @@ export function createRemoteApp( if (fileLogger) { fileLogger.warn( { err: err.message }, - "Keychain unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved.", + "Secret store unavailable; skipping plaintext-secret migration on this read. Existing mcp.json plaintext values are preserved.", ); } // Partial-migration semantics: if `set` threw partway through From 339aea37296d09a284c9b4cab99d70ce2a433f43 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 18:05:26 -0400 Subject: [PATCH 245/941] fix(auth): replace the secrets-file lock with optimistic verify-and-retry, and address review round 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, interleaved in the same files because both are about the same read-modify-write path. ## The lock is gone (maintainer decision on the round-5/7/8 finding) Three consecutive review rounds found a real race in the `mkdir` lock, and the last one is not patchable with what Node exposes: claiming a stale lock needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`). Without it, `canSteal` inspects one lock but `rename` acts on whatever is at that path *later*, so a waiter that lost the first race can move the winner's fresh lock aside and enter alongside it. Rather than patch it a fourth time, writers now collide and the loser notices. `mutate` reads `M0`, applies the mutation to get `M1`, writes it, then reads back `M2`; if they differ, someone wrote in between, so it re-applies onto what they left and retries, bounded. The comparison is over the **whole map**, and that is the load-bearing detail. Checking only the entry we touched passes in exactly the case that loses data — ours is present, and it is the *other* writer's that is gone. With A and B from the same `M0`: A writes `MA`, B writes `MB` and clobbers A; B verifies and correctly sees `MB`; A verifies, sees `M2 !== MA`, re-applies onto `MB`, writes again. Both survive, and the writer that was clobbered is the one that repairs it. Two things this deliberately does not claim: - It is **not mutual exclusion**. A process that crashes between its write and its verifying read cannot notice it was clobbered, and nothing else will. That window is strictly narrower than the lock's — which lost updates with every participant alive — and needs no primitive Node lacks. Stated in the code rather than left to be rediscovered in review. - Non-convergence **fails loudly**. After five lost rounds `set` throws rather than returning, because returning would reintroduce the silent loss with extra steps. `delete` stays silent, per the interface contract. `serialize` is now keyed on the resolved path **process-wide** rather than per instance. Two `FileSecretStore`s on one file are ordinary — the resolved store holds one and `absorbFileSecretsIntoKeyring` builds another — and a per-instance queue does not order them against each other at all. The new convergence test caught this on its first run: two instances racing `set` lost an entry. In-process is now correct by construction; the verify covers what it cannot see, a second Inspector process. The hand-off's read → copy → delete was the lock's other user. Its delete is now conditional: the file is re-read immediately before removal and compared against what was copied, and it declines to delete when they differ. Leaving the file costs nothing (keychain-wins makes the next run idempotent) while deleting on a stale read costs a secret. ## Round 9 (two inline, two suppressed) - **`KeyringSecretStore.getStrict` let raw binding errors escape.** Both plaintext migrations keep their source file only when they catch `SecretStoreUnavailableError`, so a transient keychain failure 500'd `GET /api/servers` instead of abandoning the migration. Wrapped, as `set` already does. - **`FileSecretStore` had no strict read at all**, so `secretStoreGetStrict` fell back to the tolerant `get` and mapped every read failure to `null` — leaving the exact inversion the seam was introduced to prevent open on the store it was introduced for. - **`readOnDiskEncryption` reported "encrypted" for an envelope it cannot open.** Naming the cipher is not the same as being openable: a file with no `kdf`/`data`, or a `data` that is not `iv.tag.ciphertext`, produced the quiet reassuring footer for a file whose next save was guaranteed to fail. - **`/api/config` kept a stale descriptor when the resolver returned `undefined`**, because the conditional spread left the startup value standing. Assigned unconditionally now; `c.json` omits the key. Full `npm run ci` green. Signed-off-by: cliffhall <cliff@futurescale.com> --- .../auth/node/file-secret-store.test.ts | 517 ++++++----------- .../auth/node/secret-store-selection.test.ts | 69 ++- .../auth/node/secret-store.test.ts | 37 ++ .../mcp/remote/server-config.test.ts | 27 + core/auth/node/file-secret-store.ts | 534 ++++++------------ core/auth/node/secret-store-selection.ts | 81 ++- core/auth/node/secret-store.ts | 26 +- core/mcp/remote/node/server.ts | 8 +- 8 files changed, 538 insertions(+), 761 deletions(-) diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 8922e4cc..891218c7 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -8,16 +8,13 @@ * agree with any of those without them being true. */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { existsSync } from "node:fs"; import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { - acquireLock, FileSecretStore, readSecretFilePermissions, - SecretFileLockTimeoutError, SecretFileKeyMismatchError, tightenSecretFilePermissions, } from "@inspector/core/auth/node/file-secret-store.js"; @@ -876,380 +873,188 @@ describe("payload shape validation", () => { }); }); -describe("acquireLock", () => { - it("excludes a second holder while the first is live", async () => { - const target = path.join(tmpDir, "locked.json"); - const release = await acquireLock(target); - let secondTaken = false; - const second = acquireLock(target).then((r) => { - secondTaken = true; - return r; +describe("getStrict (round 9)", () => { + it("returns the value like get does when the file is readable", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("srv", "env:A", "1"); + expect(await store.getStrict("srv", "env:A")).toBe("1"); + expect(await store.getStrict("srv", "env:MISSING")).toBe(null); + }); + + it("throws where get would answer null, so a migration cannot write over a newer value", async () => { + // The whole point of the seam: `get` maps an unreadable store to `null`, + // and both plaintext migrations *write* on `null` and then delete the + // disk copy. Falling back to `get` here left that inversion open on the + // very store the seam was added for. + const writer = new FileSecretStore({ + filePath: filePath(), + passphrase: "right-key", }); - // Still held: the waiter must not have been let through. - await new Promise((r) => setTimeout(r, 60)); - expect(secondTaken).toBe(false); - await release(); - await ( - await second - )(); - expect(secondTaken).toBe(true); - }); + await writer.set("srv", "env:A", "1"); - it("steals a lock left behind by a killed process", async () => { - // A container killed with SIGKILL leaves the directory. A lock only a - // clean exit can release would turn that into permanently broken writes — - // strictly worse than the race it prevents. - const target = path.join(tmpDir, "stale.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - const release = await acquireLock(target); - await release(); - }); - - it("creates the parent directory so a first write is locked too", async () => { - // The first run has no secrets directory yet — `writeStoreFile` creates - // it later. Without creating it here, mkdir failed ENOENT and the lock - // was skipped, so two processes' *first* writes could both proceed - // unlocked and overwrite each other. First writes are exactly when two - // processes are most likely to start together. - const target = path.join(tmpDir, "no-such-dir", "secrets.json"); - const release = await acquireLock(target, { pollMs: 10 }); - // A real lock was taken: a second acquisition must not be handed out. - let secondTaken = false; - // Held, not floated: an acquisition still running when the test ends - // would race `afterEach` removing the temp directory. - const second = acquireLock(target, { pollMs: 10 }).then((r) => { - secondTaken = true; - return r; + const wrongKey = new FileSecretStore({ + filePath: filePath(), + passphrase: "wrong-key", }); - await new Promise((r) => setTimeout(r, 60)); - expect(secondTaken).toBe(false); - await release(); - await ( - await second - )(); - }); - - it("releases only a lock it still owns", async () => { - // A holder whose work outran the stale threshold must not delete the - // next owner's lock on its way out — that would drop a third writer into - // a section two processes believed they held. - const target = path.join(tmpDir, "owned.json"); - const lockDir = `${target}.lock`; - const release = await acquireLock(target); - // Simulate a steal: someone judged us stale, removed our lock, and took - // their own. - await fs.rm(lockDir, { recursive: true, force: true }); - await fs.mkdir(lockDir); - await fs.writeFile(path.join(lockDir, "owner"), "someone-else", "utf-8"); - await release(); - // Their lock is untouched. - expect(await fs.readFile(path.join(lockDir, "owner"), "utf-8")).toBe( - "someone-else", + expect(await wrongKey.get("srv", "env:A")).toBe(null); + await expect(wrongKey.getStrict("srv", "env:A")).rejects.toBeInstanceOf( + SecretFileKeyMismatchError, ); - await fs.rm(lockDir, { recursive: true, force: true }); }); - it("fails the mutation rather than writing past a live holder", async () => { - // Falling through to an unlocked write under contention would be the - // worst possible moment for it: another writer is provably active. `set` - // is allowed to hard-fail, so this surfaces as the documented 503. - // - // The holder heartbeats faster than it goes stale, so the waiter can - // never mistake it for dead and must time out instead. - const target = path.join(tmpDir, "contended.json"); - const timings = { - staleMs: 400, - timeoutMs: 250, - heartbeatMs: 50, - pollMs: 10, - }; - const release = await acquireLock(target, timings); - try { - await expect(acquireLock(target, timings)).rejects.toBeInstanceOf( - SecretFileLockTimeoutError, - ); - } finally { - await release(); - } - }); - - it("does not steal from a holder that is merely slow", async () => { - // The heartbeat is what separates "dead" from "slow". Without it a - // holder on a slow filesystem loses its lock mid-section and the lost - // update comes straight back — so the waiter here must time out rather - // than acquire, even though it waits well past the stale threshold. - const target = path.join(tmpDir, "slow.json"); - const release = await acquireLock(target, { - staleMs: 120, - timeoutMs: 60_000, - heartbeatMs: 20, - pollMs: 10, + it("wraps a filesystem failure as SecretStoreUnavailableError", async () => { + // Not a typed refusal — a raw ENOTDIR — which must still arrive as + // something the migrations' catch recognises. + const blocker = path.join(tmpDir, "blocker"); + await fs.writeFile(blocker, "x", "utf-8"); + const store = new FileSecretStore({ + filePath: path.join(blocker, "secrets.json"), }); - try { - let stolen = false; - // Held and settled below rather than floated, so the waiter cannot - // still be running when `afterEach` removes the temp directory. - const waiter = acquireLock(target, { - staleMs: 120, - timeoutMs: 400, - pollMs: 10, - }) - .then(async (r) => { - stolen = true; - await r(); - }) - .catch(() => { - // Timed out, which is the expected outcome. - }); - await waiter; - expect(stolen).toBe(false); - } finally { - await release(); - } - }); - - it("does not steal a stale lock whose owner process is still alive", async () => { - // An old mtime means the holder has not heartbeated lately, and there are - // two very different reasons: it died, or it is alive but not running — - // SIGSTOPed, event-loop-blocked, or on a machine that suspended. Taking - // the lock on elapsed time alone would pull it out from under a live - // critical section, which is the lost update the lock exists to prevent. - const target = path.join(tmpDir, "suspended.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - // Stamp it with *this* process, which is definitionally alive, and age it - // well past stale — the shape of a suspended holder. - await fs.writeFile( - path.join(lockDir, "owner"), - JSON.stringify({ - token: "someone", - pid: process.pid, - host: os.hostname(), - }), - "utf-8", + await expect(store.getStrict("srv", "env:A")).rejects.toBeInstanceOf( + SecretStoreUnavailableError, ); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - - await expect( - acquireLock(target, { staleMs: 50, timeoutMs: 150, pollMs: 10 }), - ).rejects.toBeInstanceOf(SecretFileLockTimeoutError); - // Their lock survives the attempt. - expect(existsSync(lockDir)).toBe(true); - await fs.rm(lockDir, { recursive: true, force: true }); - }); - - it("steals a stale lock whose owner process is gone", async () => { - // The case the stealing exists for. A pid that no longer resolves is a - // dead holder, and refusing to break its lock would turn one killed - // container into permanently broken secret writes. - const target = path.join(tmpDir, "dead.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile( - path.join(lockDir, "owner"), - // A pid that cannot be running: `kill(0)` answers ESRCH. - JSON.stringify({ token: "gone", pid: 2 ** 31 - 1, host: os.hostname() }), - "utf-8", - ); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - - const release = await acquireLock(target, { - staleMs: 50, - timeoutMs: 2_000, - pollMs: 10, - }); - await release(); }); +}); +describe("readOnDiskEncryption rejects an envelope it could not open", () => { + // Naming the cipher is not the same as being openable, and reporting + // "encrypted" for a file whose next save is guaranteed to fail is the + // quiet, reassuring footer arriving for the worst case. it.each([ - ["not JSON at all", "{ not json"], - ["JSON that is not an object", '"just-a-string"'], - ["an object with no token", JSON.stringify({ pid: 1, host: "h" })], - ])("steals a stale lock whose owner stamp is %s", async (_label, stamp) => { - // An unusable stamp cannot answer the liveness question, so the mtime is - // all there is — and stealing on it is right, because the alternative is - // a lock nothing can ever break. - const target = path.join(tmpDir, `stamp-${_label.length}.json`); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile(path.join(lockDir, "owner"), stamp, "utf-8"); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - - const release = await acquireLock(target, { - staleMs: 50, - timeoutMs: 2_000, - pollMs: 10, + [ + "no kdf or data", + { version: 1, encryption: "aes-256-gcm" }, + "encrypted envelope is missing its kdf or data", + ], + [ + "data that is not iv.tag.ciphertext", + { + version: 1, + encryption: "aes-256-gcm", + kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 }, + data: "only-one-part", + }, + "encrypted payload is not iv.tag.ciphertext", + ], + ])("reports unreadable for %s", async (_label, envelope, detail) => { + await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8"); + const store = new FileSecretStore({ filePath: filePath() }); + expect(await store.readOnDiskEncryption()).toEqual({ + state: "unreadable", + detail, }); - await release(); + }); +}); + +describe("concurrent writers (no lock, optimistic verify-and-retry)", () => { + // The lock these replace was removed after three review rounds each found + // a real race in it; the last needed a compare-and-swap on a directory + // entry that Node does not expose. The store now lets writers collide and + // makes the loser notice — so these assert convergence, which is the + // property that actually matters, rather than mutual exclusion, which it + // no longer claims. + + it("two stores racing set on one file keep both entries", async () => { + // The classic lost update: both read the same map, both write. Whichever + // lands second clobbers the other's entry — and the clobbered writer's + // verifying read is what catches it and re-applies. + const a = new FileSecretStore({ filePath: filePath() }); + const b = new FileSecretStore({ filePath: filePath() }); + await Promise.all([a.set("srv", "env:A", "1"), b.set("srv", "env:B", "2")]); + + const reader = new FileSecretStore({ filePath: filePath() }); + expect(await reader.get("srv", "env:A")).toBe("1"); + expect(await reader.get("srv", "env:B")).toBe("2"); }); - it("steals a stale lock whose stamp carries no usable pid", async () => { - // A stamp with a non-numeric pid names no process to ask about, so it - // cannot vouch for the holder being alive. - const target = path.join(tmpDir, "nopid.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile( - path.join(lockDir, "owner"), - JSON.stringify({ token: "t", pid: "not-a-number", host: os.hostname() }), - "utf-8", + it("keeps every entry when many independent stores write at once", async () => { + // Independent instances, so the in-process queue does not serialize them + // for us — each is its own writer with its own view of the file. + const stores = Array.from( + { length: 8 }, + () => new FileSecretStore({ filePath: filePath() }), ); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); + await Promise.all(stores.map((s, i) => s.set("srv", `env:K${i}`, `v${i}`))); - const release = await acquireLock(target, { - staleMs: 50, - timeoutMs: 2_000, - pollMs: 10, - }); - await release(); - }); - - it("steals a stale lock stamped by another machine", async () => { - // A shared filesystem: we cannot ask this host about that host's pid, so - // the mtime is all there is. Refusing would make a lock nothing can ever - // break. - const target = path.join(tmpDir, "remote.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile( - path.join(lockDir, "owner"), - JSON.stringify({ - token: "elsewhere", - pid: process.pid, - host: "some-other-host", - }), - "utf-8", - ); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - - const release = await acquireLock(target, { - staleMs: 50, - timeoutMs: 2_000, - pollMs: 10, - }); - await release(); - }); - - it("stops heartbeating a lock that is no longer ours", async () => { - // After a steal, the directory belongs to someone else. Continuing to - // touch it would keep *their* lock looking alive after they died, which - // turns a stale lock into an unbreakable one. - const target = path.join(tmpDir, "stolen.json"); - const lockDir = `${target}.lock`; - const release = await acquireLock(target, { heartbeatMs: 10 }); - // Someone judges us dead and takes it. - await fs.writeFile( - path.join(lockDir, "owner"), - JSON.stringify({ token: "thief", pid: process.pid, host: os.hostname() }), - "utf-8", - ); - const afterSteal = new Date(Date.now() - 30_000); - await fs.utimes(lockDir, afterSteal, afterSteal); - // Give the heartbeat several chances to (wrongly) refresh it. - await new Promise((r) => setTimeout(r, 80)); - const mtime = (await fs.stat(lockDir)).mtimeMs; - expect(Date.now() - mtime).toBeGreaterThan(1_000); - // And our release leaves their lock alone, since the token no longer - // matches. - await release(); - expect(existsSync(lockDir)).toBe(true); - await fs.rm(lockDir, { recursive: true, force: true }); - }); - - it("elects a single winner when two waiters find the same stale lock", async () => { - // `rm`-then-`mkdir` is not an election: both waiters judge the lock - // stale, the first removes it and takes a fresh one, and the second then - // removes *that* — putting both inside the section at once, which is the - // lost update the takeover exists to prevent. The rename is atomic, so - // exactly one waiter can claim a given stale lock. - const target = path.join(tmpDir, "elected.json"); - const lockDir = `${target}.lock`; - await fs.mkdir(lockDir, { recursive: true }); - await fs.writeFile( - path.join(lockDir, "owner"), - // A pid that cannot be running, so both waiters agree it is stale. - JSON.stringify({ token: "dead", pid: 2 ** 31 - 1, host: os.hostname() }), - "utf-8", - ); - const old = new Date(Date.now() - 60_000); - await fs.utimes(lockDir, old, old); - - let inside = 0; - let maxInside = 0; - const timings = { staleMs: 50, timeoutMs: 10_000, pollMs: 5 }; - const run = async () => { - const release = await acquireLock(target, timings); - inside += 1; - maxInside = Math.max(maxInside, inside); - await new Promise((r) => setTimeout(r, 40)); - inside -= 1; - await release(); - }; - await Promise.all([run(), run()]); - // Never two holders at once. - expect(maxInside).toBe(1); - }); - - it("cleans up and reports when it cannot stamp ownership", async () => { - // mkdir succeeded, the owner write did not. Returning a no-op release - // here would enter the critical section unlocked *and* leave the - // directory behind, so every later writer queues behind an orphan nobody - // can deliberately release. - const target = path.join(tmpDir, "unstampable.json"); - vi.resetModules(); - vi.doMock("node:fs/promises", async () => { - const actual = - await vi.importActual<typeof import("node:fs/promises")>( - "node:fs/promises", - ); - return { - ...actual, - default: actual, - writeFile: vi.fn((p: string, ...rest: unknown[]) => - String(p).endsWith(`${path.sep}owner`) - ? Promise.reject(new Error("ENOSPC: no space left on device")) - : (actual.writeFile as (...a: unknown[]) => Promise<void>)( - p, - ...rest, - ), - ), - }; - }); - try { - const mod = - await import("@inspector/core/auth/node/file-secret-store.js"); - await expect(mod.acquireLock(target)).rejects.toThrow( - /Could not initialize the lock/, - ); - // And nothing is left obstructing the next writer. - expect(existsSync(`${target}.lock`)).toBe(false); - } finally { - vi.doUnmock("node:fs/promises"); - vi.resetModules(); + const reader = new FileSecretStore({ filePath: filePath() }); + for (let i = 0; i < stores.length; i++) { + expect(await reader.get("srv", `env:K${i}`)).toBe(`v${i}`); } }); - it("serializes concurrent writers through two independent store instances", async () => { - // Two `FileSecretStore` objects have separate in-process queues, so this - // is the cross-process race in miniature: without the file lock the second - // write drops the first one's entry. - const target = path.join(tmpDir, "shared.json"); - const a = new FileSecretStore({ filePath: target }); - const b = new FileSecretStore({ filePath: target }); + it("converges when the racing writers are encrypted", async () => { + // Same property with a scrypt derivation inside every attempt, which is + // where the window is widest. + const opts = { filePath: filePath(), passphrase: "hunter2" }; + const a = new FileSecretStore(opts); + const b = new FileSecretStore(opts); + await Promise.all([a.set("srv", "env:A", "1"), b.set("srv", "env:B", "2")]); + + const reader = new FileSecretStore(opts); + expect(await reader.get("srv", "env:A")).toBe("1"); + expect(await reader.get("srv", "env:B")).toBe("2"); + }); + + it("a racing delete and set both land", async () => { + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("srv", "env:OLD", "x"); + + const a = new FileSecretStore({ filePath: filePath() }); + const b = new FileSecretStore({ filePath: filePath() }); await Promise.all([ - a.set("alpha", "env:A", "1"), - b.set("alpha", "env:B", "2"), + a.delete("srv", "env:OLD"), + b.set("srv", "env:NEW", "y"), ]); - expect(await a.get("alpha", "env:A")).toBe("1"); - expect(await a.get("alpha", "env:B")).toBe("2"); + + const reader = new FileSecretStore({ filePath: filePath() }); + expect(await reader.get("srv", "env:OLD")).toBe(null); + expect(await reader.get("srv", "env:NEW")).toBe("y"); + }); + + // A writer that never wins. Simulated by replacing the file *behind* the + // store immediately after each write — with a raw `fs` write rather than a + // second store, because every store on this path now shares one in-process + // queue and writing through one from inside a queued section would + // deadlock on it. + const clobberAfterEveryWrite = (store: FileSecretStore): void => { + const inner = store as unknown as { + writeMap: (m: Record<string, string>) => Promise<void>; + }; + const realWrite = inner.writeMap.bind(store); + let n = 0; + inner.writeMap = async (map: Record<string, string>) => { + await realWrite(map); + await fs.writeFile( + filePath(), + JSON.stringify({ + version: 1, + encryption: "none", + secrets: { "srv:env:INTRUDER": `v${n++}` }, + }), + "utf-8", + ); + }; + }; + + it("reports non-convergence instead of silently dropping the value", async () => { + // Returning after N attempts would reintroduce the silent loss with + // extra steps, so this must reject — and say which value was not saved. + const store = new FileSecretStore({ filePath: filePath() }); + clobberAfterEveryWrite(store); + + await expect(store.set("srv", "env:MINE", "1")).rejects.toThrow( + /kept overwriting it/, + ); + }); + + it("a non-convergent delete stays silent, per the interface contract", async () => { + // `delete` reports nothing by contract — only `set` hard-fails — so a + // delete that cannot converge must still resolve rather than throw. + const store = new FileSecretStore({ filePath: filePath() }); + await store.set("srv", "env:A", "1"); + clobberAfterEveryWrite(store); + + await expect(store.delete("srv", "env:A")).resolves.toBeUndefined(); }); }); diff --git a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts index 4767aab3..56c901e8 100644 --- a/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store-selection.test.ts @@ -772,35 +772,58 @@ describe("absorbFileSecretsIntoKeyring", () => { ); }); - it("holds the file lock for the whole hand-off", async () => { - // Read → copy → delete must be one transaction. Another Inspector - // completing a `set` between the read and the delete would have its - // brand-new secret removed without ever being copied — a write that - // reported success and then vanished. + it("declines to delete a file that changed while the hand-off ran", async () => { + // Read → copy → delete is no longer one transaction: the store dropped + // its cross-process lock in favour of optimistic writes, so another + // Inspector can complete a `set` between the read and the delete. That + // entry has not been copied, so deleting the file would discard a write + // that reported success. The hand-off re-reads immediately before the + // delete and backs off when the contents moved. + const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); + process.env.MCP_INSPECTOR_SECRET_FILE = filePath; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const mod = await loadWithProbe(true); + + // Stand in for the other Inspector: write to the file *during* the copy, + // which is exactly the window the old lock covered. + const keyring = new InMemorySecretStore(); + const { FileSecretStore } = + await import("@inspector/core/auth/node/file-secret-store.js"); + const original = keyring.set.bind(keyring); + let intruded = false; + keyring.set = async (id: string, field: string, value: string) => { + await original(id, field, value); + if (!intruded) { + intruded = true; + await new FileSecretStore({ filePath }).set("other", "env:LATE", "z"); + } + }; + + await mod.absorbFileSecretsIntoKeyring(keyring); + + // The file survives, and so does the entry that arrived late. + expect(existsSync(filePath)).toBe(true); + expect( + await new FileSecretStore({ filePath }).get("other", "env:LATE"), + ).toBe("z"); + expect(warn.mock.calls.flat().join("\n")).toMatch( + /changed while that ran, so it has been left in place/, + ); + }); + + it("deletes the file when nothing changed under it", async () => { + // The ordinary path: no concurrent writer, so the comparison matches and + // the now-redundant file goes away. const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" }); process.env.MCP_INSPECTOR_SECRET_FILE = filePath; vi.spyOn(console, "warn").mockImplementation(() => {}); const mod = await loadWithProbe(true); - const { acquireLock } = - await import("@inspector/core/auth/node/file-secret-store.js"); - // Hold the lock as the "other process" would. - const release = await acquireLock(filePath, { heartbeatMs: 20 }); - let finished = false; - const handOff = mod - .absorbFileSecretsIntoKeyring(new InMemorySecretStore()) - .then(() => { - finished = true; - }); + const keyring = new InMemorySecretStore(); + await mod.absorbFileSecretsIntoKeyring(keyring); - await new Promise((r) => setTimeout(r, 120)); - // Blocked on us, not steaming ahead and deleting the file. - expect(finished).toBe(false); - expect(existsSync(filePath)).toBe(true); - - await release(); - await handOff; - expect(finished).toBe(true); + expect(existsSync(filePath)).toBe(false); + expect(await keyring.get("srv", "oauthClientSecret")).toBe("from-file"); }); it("runs the hand-off for an explicitly configured keyring too", async () => { diff --git a/clients/web/src/test/integration/auth/node/secret-store.test.ts b/clients/web/src/test/integration/auth/node/secret-store.test.ts index a2e079ed..10cc8aa7 100644 --- a/clients/web/src/test/integration/auth/node/secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/secret-store.test.ts @@ -378,6 +378,43 @@ describe("KeyringSecretStore (mocked native bindings)", () => { }); }); + describe("getStrict wraps its failures (round 9)", () => { + // "Strict" must mean the caller *learns* the read failed, in a form it + // can act on. Both plaintext migrations keep their source file only when + // they catch `SecretStoreUnavailableError`; a raw binding error escaping + // here sails past that and 500s `GET /api/servers` instead of abandoning + // the migration. + it("returns the value when the keychain answers", async () => { + await store.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh"); + expect( + await store.getStrict("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).toBe("shh"); + }); + + it("throws the typed error when the read itself fails", async () => { + keyringMocks.failures.getThrows = true; + await expect( + store.getStrict("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).rejects.toBeInstanceOf(KeychainUnavailableError); + }); + + it("throws the typed error when AsyncEntry construction fails", async () => { + // #1848's shape: the platform store is unreachable, and it is the + // constructor that says so. + keyringMocks.failures.constructorThrows = true; + await expect( + store.getStrict("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).rejects.toBeInstanceOf(KeychainUnavailableError); + }); + + it("still answers null for a genuinely absent entry", async () => { + // Strictness is about the *store* failing, not about absence. + expect( + await store.getStrict("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).toBe(null); + }); + }); + describe("probeKeyringAvailable (#1950)", () => { // The input to the automatic-fallback policy. It has to answer "is the // keychain *usable*", not "did the package import" — the container that diff --git a/clients/web/src/test/integration/mcp/remote/server-config.test.ts b/clients/web/src/test/integration/mcp/remote/server-config.test.ts index 122ebf07..26e138b1 100644 --- a/clients/web/src/test/integration/mcp/remote/server-config.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-config.test.ts @@ -122,6 +122,33 @@ describe("GET /api/config re-resolves secretStorage per request (#1950)", () => expect(second.secretStorage?.plaintext).toBe(false); }); + it("clears the field when the resolver says it is not known (round 9)", async () => { + // The resolver's contract is that `undefined` means "not known right + // now". Spreading it in conditionally left the *startup* descriptor + // standing in its place, so a store that became undescribable kept + // serving a stale, confident answer — the one thing this surface must + // never do. + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + initialConfig: { + defaultEnvironment: {}, + secretStorage: { + kind: "file", + reason: "fallback", + durable: true, + plaintext: true, + path: "/tmp/secrets.json", + }, + }, + secretStorageResolver: async () => undefined, + }); + + const body = (await ( + await app.request(new Request("http://test/api/config")) + ).json()) as { secretStorage?: unknown }; + expect(body.secretStorage).toBeUndefined(); + }); + it("falls back to the startup payload when no resolver is supplied", async () => { // Embedders and the test suite pass no resolver; they must keep the // previous behaviour rather than losing the field. diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index c4262bff..fef8d424 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -60,7 +60,6 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; -import * as os from "node:os"; import * as path from "node:path"; import { readStoreFile, writeStoreFile } from "../../storage/store-io.js"; import { @@ -195,311 +194,34 @@ function decodeParts( } /** - * How long a lock may be held before another process treats it as - * abandoned. Generous next to the work it guards (a read, one scrypt - * derivation, an atomic write — single-digit milliseconds), because the - * cost of being wrong is asymmetric: waiting a second too long is - * invisible, while stealing a live lock reintroduces the very race the - * lock exists to close. - */ -const LOCK_STALE_MS = 10_000; -/** - * How long to wait for a live lock. Deliberately longer than - * {@link LOCK_STALE_MS}, so a waiter always outlives the point at which the - * holder becomes stealable — with the shorter value it used to have, a - * waiter gave up before it could ever steal an abandoned lock, which is the - * one case the stealing exists for. - */ -const LOCK_TIMEOUT_MS = 15_000; -const LOCK_POLL_MS = 25; -/** - * How often a holder touches its lock to prove it is alive. + * How many times a mutation will re-apply itself before giving up. * - * Comfortably under {@link LOCK_STALE_MS} so a live holder is never judged - * stale, which is what makes the two mechanisms coherent: staleness then - * means *dead*, not merely *slow*. Without the heartbeat, a holder on a - * slow filesystem — or one whose scrypt derivation ran long — has its lock - * stolen while it is still inside the critical section, reinstating exactly - * the lost update the lock exists to prevent. + * Each attempt is a full read-modify-write plus a verifying read, so the + * bound is on *rounds lost to another writer*, not on retries of a failing + * operation. Two Inspector processes writing the same file at the same + * instant is already rare; losing five consecutive rounds to one means + * something other than ordinary contention is happening, and reporting that + * is more honest than looping. */ -const LOCK_HEARTBEAT_MS = 3_000; - -const sleep = (ms: number): Promise<void> => - new Promise((resolve) => setTimeout(resolve, ms)); +const MAX_WRITE_ATTEMPTS = 5; /** - * Thrown when a lock is held by someone else for longer than - * {@link LOCK_TIMEOUT_MS}. + * In-process mutation queues, one per resolved secrets-file path. * - * A timeout must **fail the mutation**, never fall through and write - * anyway: proceeding unlocked past a live holder is precisely the - * lost-update the lock exists to prevent, and doing it under contention — - * the one moment another writer is provably active — would be the worst - * possible time. `set` is allowed to hard-fail by the store contract, so - * this surfaces as the documented 503 and the user retries. + * Process-wide rather than per-store: see `FileSecretStore.serialize`. */ -export class SecretFileLockTimeoutError extends SecretStoreUnavailableError { - constructor(filePath: string) { - super( - `Timed out waiting for another process to finish writing the secrets file at ${filePath}. Another Inspector may be busy; try again.`, - ); - this.name = "SecretFileLockTimeoutError"; - } -} +const fileQueues = new Map<string, Promise<unknown>>(); -/** - * Take an exclusive, cross-process lock on `filePath`. - * - * `mkdir` is the primitive rather than a lock *file*: it is atomic and - * fails with `EEXIST` on every POSIX filesystem and on Windows, and it - * needs no `O_EXCL` open-flag juggling. Returns the release function. - * - * **Ownership is stamped, not assumed.** The holder writes a random token - * inside the lock directory and release only removes a lock still carrying - * that token. Without it, a holder whose work outran the stale threshold - * would delete the *next* owner's lock on its way out, letting a third - * writer into a critical section two processes believed they held — a - * rarer and much more confusing corruption than the one being prevented. - * - * **Stale locks are stolen, and that is not optional.** A container killed - * with SIGKILL leaves the directory behind, and a lock that only a - * cooperative exit can release would make one `docker kill` brick secret - * writes permanently — turning a rare lost write into a permanent outage, - * which is strictly worse. Staleness is judged by the directory's mtime, - * which a live holder refreshes on a heartbeat — so "stale" means the - * holder is *dead*, never merely slow. That distinction is what keeps the - * stealing from reinstating the very race the lock prevents. - * - * Throws {@link SecretFileLockTimeoutError} when another holder keeps it - * past the timeout. The *only* path that proceeds unlocked is a filesystem - * that cannot create the lock at all (a read-only mount, an exotic - * filesystem) — there, refusing would deny every write on a box where the - * single-process case is still perfectly safe, and a genuinely unwritable - * location fails at the write itself with a better message than a lock - * error would give. - */ -/** - * Timing policy, overridable so the tests can exercise contention, - * staleness, and timeout without spending the real intervals. Production - * callers pass nothing and get the constants above. - */ -export interface LockTimings { - staleMs?: number; - timeoutMs?: number; - heartbeatMs?: number; - pollMs?: number; -} - -export async function acquireLock( - filePath: string, - timings: LockTimings = {}, -): Promise<() => Promise<void>> { - const staleMs = timings.staleMs ?? LOCK_STALE_MS; - const timeoutMs = timings.timeoutMs ?? LOCK_TIMEOUT_MS; - const heartbeatMs = timings.heartbeatMs ?? LOCK_HEARTBEAT_MS; - const pollMs = timings.pollMs ?? LOCK_POLL_MS; - const lockDir = `${filePath}.lock`; - const ownerFile = `${lockDir}/owner`; - const token = crypto.randomBytes(16).toString("hex"); - // The stamp a would-be thief consults before taking this lock: the token - // proves ownership on release, the pid and host let a waiter ask the OS - // whether we are still alive rather than inferring it from elapsed time. - const owner: LockOwner = { token, pid: process.pid, host: os.hostname() }; - const noop = async (): Promise<void> => {}; - const deadline = Date.now() + timeoutMs; - - // The parent must exist before the lock can. On a first run it does not — - // `writeStoreFile` creates it later — so without this the very first write - // of every process failed with ENOENT and proceeded unlocked, meaning two - // first writes could both bypass the lock, read an empty map, and overwrite - // each other. The first write is exactly when two processes are most likely - // to start together. - try { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - } catch { - // Unwritable parent. The mkdir below will fail too, and the write after - // it will fail with a message about the actual problem. - } - - for (;;) { - let created = false; - try { - await fs.mkdir(lockDir, { recursive: false }); - created = true; - // Stamp ownership before entering the section. If this fails the lock - // exists but says nothing about who holds it, so nobody could ever - // release it deliberately and every later writer would queue behind an - // orphan until it aged out. Clean up and report, rather than proceeding - // unlocked *and* leaving the obstruction behind. - await fs.writeFile(ownerFile, JSON.stringify(owner), "utf-8"); - // Prove liveness for as long as we hold it. `unref` so a held lock - // never keeps the process alive on its own — the interval is a - // side-channel, not work anyone is waiting for. - const heartbeat = setInterval(() => { - // Confirm the lock is still ours before refreshing it. If it was - // stolen (only possible when we looked dead, or across hosts where - // the pid check cannot apply), the directory now belongs to someone - // else — and touching it would keep *their* lock looking alive after - // they died, which is how a stale lock becomes an unbreakable one. - void (async () => { - try { - const held = parseOwner(await fs.readFile(ownerFile, "utf-8")); - if (held?.token !== token) { - clearInterval(heartbeat); - return; - } - const now = new Date(); - await fs.utimes(lockDir, now, now); - } catch { - // Released, stolen, or unreadable. The release below copes, and - // there is nothing useful to do from a timer. - } - })(); - }, heartbeatMs); - heartbeat.unref?.(); - return async () => { - clearInterval(heartbeat); - try { - // Release only what we still own. A lock judged stale and stolen - // now belongs to someone else, and removing it would drop them - // into a race with whoever comes next. - const held = parseOwner(await fs.readFile(ownerFile, "utf-8")); - if (held?.token !== token) return; - await fs.rm(lockDir, { recursive: true, force: true }); - } catch { - // Already gone, or unreadable. Either way there is nothing to - // release and nothing the caller can act on. - } - }; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (created) { - // We made the directory and then failed to stamp it. Ours to remove. - await fs.rm(lockDir, { recursive: true, force: true }).catch(() => {}); - throw new SecretStoreUnavailableError( - `Could not initialize the lock for the secrets file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - if (code !== "EEXIST") return noop; // cannot lock here at all — see above - if (await canSteal(lockDir, ownerFile, staleMs)) { - // Claim the stale lock by *renaming* it, not by removing it. - // - // `rm` is not an election: two waiters can both judge the same lock - // stale, and by the time the second one runs, the first may already - // have removed it and created a fresh lock of its own — which the - // second then deletes, and both enter the section together. That is - // the lost update, reached through the takeover meant to prevent it. - // - // `rename` is atomic and single-winner: exactly one waiter moves the - // directory aside, and the losers get ENOENT and fall through to - // retry against whatever the winner does next. The unique - // destination (our token) also means two winners of *different* - // stale locks cannot collide. - const claimed = `${lockDir}.stale-${token}`; - try { - await fs.rename(lockDir, claimed); - await fs.rm(claimed, { recursive: true, force: true }); - continue; - } catch { - // Lost the election, or the rename failed outright. Either way, - // fall through to the deadline check rather than looping — a - // takeover that can never succeed must time out, not spin. - } - } - if (Date.now() >= deadline) - throw new SecretFileLockTimeoutError(filePath); - await sleep(pollMs); - } - } -} - -/** - * May this waiter take a lock it found already held? - * - * Elapsed wall time alone does not answer that. An old mtime means the - * holder has not run its heartbeat lately, and there are two very different - * reasons for that: it died, or it is *alive but not running* — SIGSTOPed, - * starved by a blocked event loop, or on a machine that suspended. Expiring - * on time alone treats those identically and takes the lock out from under a - * live critical section, which is the lost update the lock exists to - * prevent, now arrived at through the machinery meant to prevent it. - * - * So the mtime only opens the question, and the owner stamp answers it: on - * the same host, ask the operating system whether that pid is still there. - * `kill(pid, 0)` sends no signal — it is the standard existence probe. - * `EPERM` counts as alive (the process exists; it just isn't ours), so a - * holder running as another user is never stolen from. - * - * The stamp is not always usable — a lock written by a different machine - * over a shared filesystem, or one whose owner file is missing or corrupt. - * There the mtime is all there is, and stealing on it is still right: the - * alternative is a lock nothing can ever break, which turns one dead process - * into a permanent outage. - */ -async function canSteal( - lockDir: string, - ownerFile: string, - staleMs: number, -): Promise<boolean> { - let held: number; - try { - held = (await fs.stat(lockDir)).mtimeMs; - } catch { - // Released between our mkdir and our stat — retry rather than steal. - return false; - } - if (Date.now() - held <= staleMs) return false; - - let owner: LockOwner | null = null; - try { - owner = parseOwner(await fs.readFile(ownerFile, "utf-8")); - } catch { - // No stamp to consult; fall through to the time-only answer. - } - if (!owner || owner.host !== os.hostname() || owner.pid === undefined) { - return true; - } - return !processIsAlive(owner.pid); -} - -/** Does this pid exist? `kill(pid, 0)` is the probe; it sends no signal. */ -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - // EPERM means it exists and belongs to someone else — still alive. - return (err as NodeJS.ErrnoException).code === "EPERM"; - } -} - -interface LockOwner { - token: string; - /** Absent when the stamp carried no usable (positive) pid. */ - pid?: number; - host: string; -} - -/** Parse an owner stamp, tolerating anything that isn't one. */ -function parseOwner(raw: string): LockOwner | null { - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) return null; - const { token, pid, host } = parsed as Partial<LockOwner>; - if (typeof token !== "string") return null; - return { - // Only a *positive* pid is usable. `-1` and `0` are wildcards to - // `process.kill` — `kill(-1, 0)` asks about every process this user - // can signal and answers "alive" for almost any host, which would make - // a garbled stamp permanently unstealable. Anything else is recorded - // as absent so the caller falls back to the mtime. - pid: typeof pid === "number" && pid > 0 ? pid : undefined, - token, - host: String(host), - }; - } catch { - return null; - } +/** Do two secret maps hold exactly the same entries? */ +function sameMap( + a: Record<string, string>, + b: Record<string, string>, +): boolean { + const ak = Object.keys(a); + if (ak.length !== Object.keys(b).length) return false; + return ak.every( + (k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k], + ); } /** @@ -527,16 +249,6 @@ export interface FileSecretStoreOptions { export class FileSecretStore implements SecretStore { readonly filePath: string; private readonly passphrase: string | undefined; - /** - * Serializes read-modify-write cycles within this process. `writeStoreFile` - * already makes each individual write atomic and ordered, but that is not - * enough here: two concurrent `set`s would each read the same "before" map - * and the second write would drop the first one's entry. The route handlers - * write a server's fields with `Promise.all`, so this is the ordinary path, - * not a race that needs contriving. - */ - private queue: Promise<unknown> = Promise.resolve(); - constructor(options: FileSecretStoreOptions) { this.filePath = options.filePath; const raw = options.passphrase ?? process.env[SECRET_KEY_ENV]; @@ -600,7 +312,26 @@ export class FileSecretStore implements SecretStore { }; } if (parsed.encryption === "none") return { state: "plaintext" }; - if (parsed.encryption === CIPHER) return { state: "encrypted" }; + if (parsed.encryption === CIPHER) { + // Naming the cipher is not the same as being openable. An envelope + // with no `kdf`/`data` — or a `data` that is not the three-part + // payload — is refused by `readMap`, so reporting it as "encrypted" + // gives the quiet, reassuring footer for a file whose very next save + // is guaranteed to fail. Check the shape the decryptor requires. + if (!parsed.kdf || typeof parsed.data !== "string") { + return { + state: "unreadable", + detail: "encrypted envelope is missing its kdf or data", + }; + } + if (!decodeParts(parsed.data)) { + return { + state: "unreadable", + detail: "encrypted payload is not iv.tag.ciphertext", + }; + } + return { state: "encrypted" }; + } return { state: "unreadable", detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`, @@ -762,33 +493,126 @@ export class FileSecretStore implements SecretStore { * whatever the first process had just added. Both writes report success; * one secret is simply gone. * - * Only mutations take the lock. Reads deliberately do not: `writeStoreFile` - * is atomic (write-temp-then-rename), so a concurrent reader sees either the - * old file or the new one and never a torn one, and making every `get` - * contend on a lock would put a filesystem round-trip in front of the - * request path for no correctness gain. + * Apply a mutation to the file, and confirm it survived. + * + * **There is no cross-process lock.** There was one — a `mkdir` election + * with an owner stamp, a heartbeat and a stale-takeover — and three + * consecutive review rounds found a real race in it. The last one is + * unfixable with what Node exposes: claiming a stale lock needs + * compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`), + * and without it a waiter that loses the race can still move the winner's + * *fresh* lock aside and enter alongside it. + * + * So this does the opposite: it lets writers collide and makes the loser + * notice. Read `M0`, apply the mutation to get `M1`, write it, then read + * back `M2`. If `M2` equals `M1` nothing interleaved. If it does not, + * someone wrote between our write and our read — so re-apply onto what + * they left and try again. + * + * The comparison is over the **whole map**, not just the entry we touched, + * and that is the entire reason this works. Checking only our own key + * passes in precisely the case that loses data: our entry is present, and + * it is the *other* writer's that is gone. Trace two writers A and B from + * the same `M0` — A writes `MA`, B writes `MB` and clobbers A's entry. B + * verifies and sees `M2 === MB`, correctly: B did nothing wrong. A + * verifies, sees `M2 !== MA`, re-applies onto `MB`, and writes again. Both + * entries survive, and the writer that was clobbered is the one that + * repairs it. + * + * **The residual window, stated plainly rather than left to be + * rediscovered:** a process that crashes between its write and its + * verifying read cannot notice it was clobbered, and nothing else will. + * That is strictly narrower than the lock's failure — which lost updates + * with every participant alive and well — and unlike the lock it needs no + * primitive Node lacks. It is not mutual exclusion and this file does not + * claim to provide it. + * + * Reads deliberately do not participate: `writeStoreFile` is atomic + * (write-temp-then-rename), so a reader sees either the old file or the + * new one, never a torn one. */ - private async withFileLock<T>(fn: () => Promise<T>): Promise<T> { - const release = await acquireLock(this.filePath); - try { - return await fn(); - } finally { - await release(); + private async mutate( + apply: (map: Record<string, string>) => Record<string, string> | null, + ): Promise<void> { + for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt++) { + let current: Record<string, string>; + try { + current = (await this.readMap()) ?? {}; + } catch (err) { + // Already a typed refusal (unreadable file / key mismatch) — + // rethrow rather than re-wrapping, so the specific advice survives. + if (err instanceof SecretStoreUnavailableError) throw err; + throw new SecretStoreUnavailableError( + `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const next = apply(current); + // `null` means "nothing to do" — a delete that matched no entry. No + // write, so nothing to verify. + if (next === null) return; + try { + await this.writeMap(next); + } catch (err) { + throw new SecretStoreUnavailableError( + `Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + let observed: Record<string, string>; + try { + observed = (await this.readMap()) ?? {}; + } catch { + // We wrote successfully and cannot read it back — most likely + // another writer replaced it with something this passphrase cannot + // open. Retrying would not help and claiming success would be a + // guess, so fall through to the non-convergence error below. + continue; + } + if (sameMap(next, observed)) return; + // Someone wrote between our write and our read. Loop: `apply` runs + // again against what they left. } + throw new SecretStoreUnavailableError( + `Could not write the secrets file at ${this.filePath}: another process kept overwriting it (gave up after ${MAX_WRITE_ATTEMPTS} attempts). Its secrets are intact; the value you just entered was not saved.`, + ); } - /** Run `fn` with the file to itself, so concurrent callers can't lose writes. */ + /** + * Run `fn` with the file to itself, so concurrent callers can't lose + * writes. + * + * Keyed on the **resolved path, process-wide** rather than on this + * instance. Two `FileSecretStore`s pointing at one file are ordinary — + * the resolved store holds one and `absorbFileSecretsIntoKeyring` + * constructs another — and a per-instance queue does not order them + * against each other at all. They then race in-process, which the + * optimistic verify is only *supposed* to catch for writers in different + * processes: both read the same map, both write, and whichever verifies + * before the other's write lands sees its own value and returns happy. + * Measured, not theorised — two instances racing `set` lost an entry on + * the first run of the convergence test below. + * + * So the in-process case is made correct by construction here, and the + * verify-and-retry in {@link mutate} covers what this cannot see: a + * second Inspector process. + */ private serialize<T>(fn: () => Promise<T>): Promise<T> { - const run = this.queue.then(fn); + const key = path.resolve(this.filePath); + const run = (fileQueues.get(key) ?? Promise.resolve()).then(fn); // Swallow on the chain only — the returned promise still rejects, so a // failed `set` surfaces to its caller while a later `set` is not poisoned // by it. This assignment is also why `this.queue` needs no `.catch()` of // its own above: it is only ever `Promise.resolve()` or an // already-swallowed promise, so it cannot reject. - this.queue = run.then( + const settled = run.then( () => {}, () => {}, ); + fileQueues.set(key, settled); + // Drop the entry once it is the tail, so a long-lived process does not + // accumulate one resolved promise per file it has ever touched. + void settled.then(() => { + if (fileQueues.get(key) === settled) fileQueues.delete(key); + }); return run; } @@ -820,6 +644,32 @@ export class FileSecretStore implements SecretStore { return out; } + /** + * The intolerant read — same lookup as {@link get}, minus the catch. + * + * Without this, `secretStoreGetStrict` fell back to `get` for the file + * store and turned every read failure into `null`. Both plaintext + * migrations *write* on `null` and then strip the disk copy, so a + * transient read error that cleared before the write let an older + * on-disk value replace a newer stored one — the same inversion the + * strict seam was introduced to prevent, still open on the store the + * seam was introduced for. + */ + async getStrict(serverId: string, field: string): Promise<string | null> { + try { + const map = await this.serialize(() => this.readMap()); + return map?.[buildAccount(serverId, field)] ?? null; + } catch (err) { + // Typed refusals (key mismatch, unsupported envelope) already carry + // the advice that fits; anything else is a filesystem failure that + // must still reach the caller as something it knows to catch. + if (err instanceof SecretStoreUnavailableError) throw err; + throw new SecretStoreUnavailableError( + `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + async get(serverId: string, field: string): Promise<string | null> { try { const map = await this.serialize(() => this.readMap()); @@ -835,27 +685,10 @@ export class FileSecretStore implements SecretStore { async set(serverId: string, field: string, value: string): Promise<void> { await this.serialize(() => - this.withFileLock(async () => { - let map: Record<string, string>; - try { - map = (await this.readMap()) ?? {}; - } catch (err) { - // Already a typed refusal (unreadable file / key mismatch) — - // rethrow rather than re-wrapping, so the specific advice survives. - if (err instanceof SecretStoreUnavailableError) throw err; - throw new SecretStoreUnavailableError( - `Could not read the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - map[buildAccount(serverId, field)] = value; - try { - await this.writeMap(map); - } catch (err) { - throw new SecretStoreUnavailableError( - `Could not write the secrets file at ${this.filePath}: ${err instanceof Error ? err.message : String(err)}`, - ); - } - }), + this.mutate((map) => ({ + ...map, + [buildAccount(serverId, field)]: value, + })), ); } @@ -876,17 +709,16 @@ export class FileSecretStore implements SecretStore { private async deleteWhere(match: (key: string) => boolean): Promise<void> { try { await this.serialize(() => - this.withFileLock(async () => { - const map = await this.readMap(); - if (!map) return; + this.mutate((map) => { const keys = Object.keys(map).filter(match); - if (keys.length === 0) return; - for (const key of keys) delete map[key]; + if (keys.length === 0) return null; + const next = { ...map }; + for (const key of keys) delete next[key]; // An emptied map keeps the file rather than unlinking it: its // presence is what records which encryption mode this install // settled on, so a later write can't silently switch modes on the // user. - await this.writeMap(map); + return next; }), ); } catch { diff --git a/core/auth/node/secret-store-selection.ts b/core/auth/node/secret-store-selection.ts index ed05cf3b..3a4ab124 100644 --- a/core/auth/node/secret-store-selection.ts +++ b/core/auth/node/secret-store-selection.ts @@ -49,7 +49,6 @@ import { secretStorageSummary, } from "../secret-storage-info.js"; import { - acquireLock, FileSecretStore, readSecretFilePermissions, tightenSecretFilePermissions, @@ -481,30 +480,27 @@ export async function absorbFileSecretsIntoKeyring( if (!fsSync.existsSync(filePath)) return; const file = new FileSecretStore({ filePath }); - // The whole read → copy → delete sequence runs under the same cross-process - // lock every mutation takes. Without it, another Inspector completing a - // `set` between our read and our delete has its brand-new secret removed - // and never copied — a write that reported success and then vanished, - // which is precisely the loss the lock exists to prevent, arrived at - // through the migration meant to preserve things. - let release: (() => Promise<void>) | undefined; - try { - release = await acquireLock(filePath); - } catch (err) { - console.warn( - `\n[mcp-inspector] Could not lock the secrets file at ${filePath} to move it into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`, - ); - return; - } - try { - await handOffUnderLock(file, filePath, keyring); - } finally { - await release(); - } + await handOff(file, filePath, keyring); } -/** The hand-off body. Split out so the lock's release is unmissable. */ -async function handOffUnderLock( +/** + * The hand-off body: read the file, copy every entry into the keychain, + * then delete the file. + * + * **The delete is conditional, because there is no lock.** The store's + * mutations are optimistic (see `FileSecretStore.mutate`), so another + * Inspector can complete a `set` between our read and our delete — and + * removing the file then discards a secret whose write reported success, + * which is the exact loss this migration exists to prevent. So the + * contents are re-read immediately before the delete and compared against + * what was copied; if they differ, the new entries are copied too and the + * comparison is made again. + * + * Bounded, and it declines to delete rather than looping forever: leaving + * the file in place costs nothing (the keychain-wins rule makes the next + * run idempotent) while deleting it on a stale comparison costs a secret. + */ +async function handOff( file: FileSecretStore, filePath: string, keyring: SecretStore, @@ -562,6 +558,33 @@ async function handOffUnderLock( return; } + // Re-read immediately before deleting. Everything above is now in the + // keychain, but the file is written without a lock, so another Inspector + // may have added an entry since — and that entry has not been copied. + // Deleting on a stale read is how a `set` that reported success loses its + // value. + let latest: Record<string, string> | null; + try { + latest = await file.readAll(); + } catch { + // Unreadable now, readable a moment ago: something changed it in a way + // this passphrase cannot open. Leave it — the copies are safe in the + // keychain and the file is the only record of whatever arrived. + console.warn( + `\n[mcp-inspector] Secrets from ${filePath} were copied into the OS keychain, but the file changed while that ran and can no longer be read, so it has been left in place.`, + ); + return; + } + if (latest && !sameSecrets(entries, latest)) { + // Leave it for the next run rather than looping here: leaving the file + // costs nothing (keychain-wins makes the retry idempotent) while + // deleting it on a stale comparison costs a secret. + console.warn( + `\n[mcp-inspector] Secrets from ${filePath} were copied into the OS keychain, but the file changed while that ran, so it has been left in place and will be moved on the next run.`, + ); + return; + } + try { await fs.rm(filePath, { force: true }); console.warn( @@ -574,6 +597,18 @@ async function handOffUnderLock( } } +/** Do two secret maps hold exactly the same entries? */ +function sameSecrets( + a: Record<string, string>, + b: Record<string, string>, +): boolean { + const ak = Object.keys(a); + if (ak.length !== Object.keys(b).length) return false; + return ak.every( + (k) => Object.prototype.hasOwnProperty.call(b, k) && a[k] === b[k], + ); +} + /** * The active store's descriptor — for the banner and `GET /api/config`. * diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 3ccf1c73..88fff072 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -407,13 +407,25 @@ export class KeyringSecretStore implements SecretStore { * caller that is about to write based on the answer. */ async getStrict(serverId: string, field: string): Promise<string | null> { - const keyring = await loadKeyring(); - if (!keyring.ok) throw new KeychainUnavailableError(keyring.err); - const entry = new keyring.mod.AsyncEntry( - SERVICE_NAME, - buildAccount(serverId, field), - ); - return (await entry.getPassword()) ?? null; + try { + const keyring = await loadKeyring(); + if (!keyring.ok) throw new KeychainUnavailableError(keyring.err); + const entry = new keyring.mod.AsyncEntry( + SERVICE_NAME, + buildAccount(serverId, field), + ); + return (await entry.getPassword()) ?? null; + } catch (err) { + // "Strict" means the caller learns the read failed — not that it + // learns in a form it cannot handle. Both plaintext migrations keep + // their source file only when they catch `SecretStoreUnavailableError`; + // a raw binding error escaping here sails past that and turns + // `GET /api/servers` (or client-config loading) into a 500 instead of + // an abandoned migration. Same wrapping `set` already does, for the + // same reason. + if (err instanceof KeychainUnavailableError) throw err; + throw new KeychainUnavailableError(err); + } } async get(serverId: string, field: string): Promise<string | null> { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index cf2f58cf..45453f98 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -689,7 +689,13 @@ export function createRemoteApp( const payload = { ...options.initialConfig, writable, - ...(secretStorage ? { secretStorage } : {}), + // Assigned unconditionally, not spread-when-truthy. The resolver's + // contract is that `undefined` means "not known right now", and a + // conditional spread leaves the *startup* descriptor from + // `initialConfig` standing in its place — so a store that became + // undescribable would keep serving a stale, confident answer. Setting + // it to `undefined` is what clears it; `c.json` omits the key. + secretStorage, ...(options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}), }; return c.json(payload); From 219765efff2d0a8f4972bb201aa37540ef279317 Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 18:33:59 -0400 Subject: [PATCH 246/941] fix(auth): address Copilot review round 10, and document the concurrency design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline is a correction to my own claim rather than a new defect. **The optimistic verify is weaker than the comment said it was.** It described the residual as a crash between the write and the verifying read. The reviewer pointed out that no crash is needed: order two writers as write-A, verify-A, write-B, verify-B and both report success while A's entry is gone — A's verify simply ran before there was anything to see, and B did nothing wrong. A crash is one instance of that shape, not the whole of it. The trace in the old comment quietly assumed A verifies *after* B writes, which nothing guarantees. Corrected in the doc comment, the README and the spec, and the honest summary is now stated rather than implied: this converges in every interleaving where the clobber lands before the verify, it is not mutual exclusion, and a deployment that needs the guarantee wants an OS-backed lock from a dedicated library — not another hand-rolled election. It remains better than what it replaced, which lost updates across a wider set of interleavings and could not be closed at all with what Node exposes. The other findings: - **An envelope naming the cipher still is not an openable one.** Round 9 added a `kdf`/`data` presence check; three dot-separated strings satisfied it while an empty tag, a 4-byte IV or `N: 3` still made every decrypt throw — so the quiet "Encrypted file" footer went to a file whose next save was guaranteed to fail. Now validates part sizes, a non-empty ciphertext, and the scrypt cost parameters (node rejects a non-power-of-two `N` synchronously, out of the derivation rather than the decrypt). A positive control asserts a file we actually wrote still reads as encrypted. - **`encryptionUnknown` + `looseMode` claimed an exposure it had not established.** With both set, `plaintext` is absent, so a `!== false` test landed on "anyone who can read it can read the secrets in it" — about a file that may well hold ciphertext we simply could not classify. Three-way now, in `secretStorageCaveat` and mirrored in the footer's tooltip, with a test on each. - **The README described a lock that no longer exists**, which is the worst kind of stale doc: it stated a durability guarantee the code had stopped providing. Rewritten to describe the actual design, including the residual. - **The private-method access in the concurrency test** now carries the justification the repo's TypeScript rules require: the asserted shape is `writeMap`'s real signature, so a rename breaks the line instead of silently detaching the stub, and a production injection seam would let the real write path drift from the tested one. Also updates `specification/v2_servers_file.md`, which still described #1356's world: one keychain-only store, and unavailability as a hard error. It now covers store selection and the probe, the file format and lazy encryption upgrade, the concurrency design and its residual, the `getStrict`/`getMany` seams and the two traps `getStrict` hit, the durability gate on migration, the keychain hand-off and its conditional delete, and how the active store is surfaced. Full `npm run ci` green. (One unrelated `AppRenderer` theme-flip test failed once under full-suite parallelism and passes in isolation with and without these changes — a flake, not a regression.) Signed-off-by: cliffhall <cliff@futurescale.com> --- README.md | 4 +- .../SecretStorageFooter.test.tsx | 30 +++++ .../SecretStorageFooter.tsx | 13 ++- .../core/auth/secret-storage-info.test.ts | 21 ++++ .../auth/node/file-secret-store.test.ts | 66 +++++++++++ core/auth/node/file-secret-store.ts | 110 ++++++++++++------ core/auth/secret-storage-info.ts | 9 ++ specification/v2_servers_file.md | 86 ++++++++------ 8 files changed, 267 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 73ffcd10..bdcbf669 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,9 @@ Setting the passphrase later is safe — the next write upgrades an existing pla The Inspector writes the file `0600` and re-tightens it at startup if something loosened it. If it _cannot_ — the file belongs to another user, or the mount is read-only — it says so in the log rather than continuing to describe the file as protected, since on that box the mode claim above is not true. -**One durable file, one writer.** Mutations take a lock (`secrets.json.lock`) so a second Inspector on the same box — a CLI run beside a web session — cannot read the same state and overwrite the first one's save. A lock left behind by a killed process is treated as abandoned after 10 seconds and stolen, because a lock that only a clean exit can release would turn one `docker kill` into permanently broken secret writes. +**Two Inspectors, one file.** Within a process, mutations are serialized per file path, so a web session's own concurrent saves cannot lose each other. Across processes — a CLI run beside a web session — there is deliberately **no lock**: writers are allowed to collide and the loser is made to notice. Each mutation reads the file, applies its change, writes, then reads back and compares the whole map; if another process wrote in between, it re-applies onto what they left and retries, and after five lost rounds it fails loudly rather than returning as though the value were saved. + +This is **not mutual exclusion**, and the residual case is worth stating: the verify only catches a clobber that has already landed, so if one Inspector reads back *before* the other's write arrives, both report success and one value is gone. That needs two Inspectors writing the same file within the gap between one's write and its read-back — narrow, but not only a crash. An earlier build did take a lock (`secrets.json.lock`); it was removed because making a `mkdir` lock single-winner on a stale takeover needs a compare-and-swap on a directory entry that Node does not expose, so it had the same class of failure with several hundred more lines and no way to close it. Three env vars affect where the file lands. `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` picks the store outright, bypassing the probe. `MCP_INSPECTOR_SECRET_FILE` names the file. Failing both, the file follows `MCP_STORAGE_DIR` — the same variable that relocates OAuth tokens and `client.json` — so mounting a volume at your configured storage directory is enough to make secrets durable there. diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx index 74cf979f..995da7d4 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.test.tsx @@ -106,6 +106,36 @@ describe("SecretStorageFooter", () => { expect(band).toHaveAttribute("data-tone", "warn"); }); + it("does not claim exposure in the tooltip when encryption is unknown", async () => { + // Mirrors `secretStorageCaveat`: `plaintext` is absent alongside + // `encryptionUnknown`, and a two-way `!== false` test would assert that + // a reader gets the secrets out of a file we could not even classify. + // The claim lives in the *tooltip*, so that is what this hovers to read + // — the visible line reports the unreadable envelope, which is a + // different (and also correct) statement. + const user = userEvent.setup(); + renderWithMantine( + <SecretStorageFooter + info={{ + ...plaintextFile, + plaintext: undefined, + looseMode: 0o644, + encryptionUnknown: "not valid JSON", + }} + />, + ); + const band = screen.getByTestId("secret-storage-footer"); + expect(band).toHaveAttribute("data-tone", "warn"); + + // The tooltip's target is the copy button, not the band around it. + await user.hover(screen.getByRole("button", { name: /Copy secrets file/ })); + const tip = await screen.findByText(/Mode 0644 could not be tightened/); + expect(tip).toHaveTextContent("could not determine"); + expect(tip).not.toHaveTextContent( + "anyone who can read the file can read the secrets in it", + ); + }); + it("renders nothing when the backend didn't report a store", () => { // A guessed answer under a secret field is worse than no answer, so the // unknown case is silence rather than a default. diff --git a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx index e60679be..18dcfa83 100644 --- a/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx +++ b/clients/web/src/components/elements/SecretStorageFooter/SecretStorageFooter.tsx @@ -197,9 +197,16 @@ function footerTooltip(info: SecretStorageInfo): string | undefined { // secrets. Claiming otherwise overstates the exposure, and a warning // that cries wolf is discounted on the occasion it is literally true. parts.push( - info.plaintext === false - ? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.` - : `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, + // Three cases, mirroring `secretStorageCaveat`. With + // `encryptionUnknown` set, `plaintext` is absent and `!== false` would + // land on the "anyone can read the secrets" wording — asserting an + // exposure we did not establish, since the envelope may hold + // ciphertext we simply could not classify. + info.encryptionUnknown !== undefined + ? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).` + : info.plaintext === false + ? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.` + : `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`, ); } else if (info.permissionsUnknown !== undefined) { parts.push( diff --git a/clients/web/src/test/core/auth/secret-storage-info.test.ts b/clients/web/src/test/core/auth/secret-storage-info.test.ts index 486b03dd..481f9e0d 100644 --- a/clients/web/src/test/core/auth/secret-storage-info.test.ts +++ b/clients/web/src/test/core/auth/secret-storage-info.test.ts @@ -113,6 +113,27 @@ describe("secretStorageCaveat", () => { ); }); + it("does not claim exposure when the encryption state is unknown", () => { + // `looseMode` with `encryptionUnknown` leaves `plaintext` absent, and a + // `!== false` test then lands on the plaintext wording — asserting that + // a reader gets the secrets. The envelope may hold ciphertext; we could + // not classify it. Claiming the worse of two unknowns is still claiming + // something we never established. + const loose: SecretStorageInfo = { + kind: "file", + reason: "fallback", + durable: true, + looseMode: 0o644, + encryptionUnknown: "not valid JSON", + path: "/tmp/secrets.json", + }; + const caveat = secretStorageCaveat(loose); + expect(secretStorageTone(loose)).toBe("warn"); + expect(caveat).toContain("0644"); + expect(caveat).toContain("could not determine"); + expect(caveat).not.toContain("anyone who can read it can read the secrets"); + }); + it("warns about a loose mode even on an encrypted file", () => { // The passphrase is then the only thing between a reader and the values, // which is worth saying rather than rendering the quiet neutral state. diff --git a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts index 891218c7..750e8fa9 100644 --- a/clients/web/src/test/integration/auth/node/file-secret-store.test.ts +++ b/clients/web/src/test/integration/auth/node/file-secret-store.test.ts @@ -936,6 +936,50 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => { }, "encrypted payload is not iv.tag.ciphertext", ], + // Three dot-separated strings are not enough: each of these satisfies a + // part-count check and still makes every decrypt throw, so reporting + // "encrypted" would hand the quiet footer to a file whose next save is + // guaranteed to fail. + [ + "an initialization vector of the wrong length", + { + version: 1, + encryption: "aes-256-gcm", + kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 }, + data: `${Buffer.alloc(4).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`, + }, + "initialization vector is 4 bytes, expected 12", + ], + [ + "an authentication tag of the wrong length", + { + version: 1, + encryption: "aes-256-gcm", + kdf: { algorithm: "scrypt", salt: "AAAA", N: 16384, r: 8, p: 1 }, + data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(3).toString("base64")}.${Buffer.alloc(8).toString("base64")}`, + }, + "authentication tag is 3 bytes, expected 16", + ], + [ + "a KDF cost parameter node would reject", + { + version: 1, + encryption: "aes-256-gcm", + kdf: { algorithm: "scrypt", salt: "AAAA", N: 3, r: 8, p: 1 }, + data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`, + }, + "kdf N is 3, which is not a power of two above 1", + ], + [ + "an unsupported KDF", + { + version: 1, + encryption: "aes-256-gcm", + kdf: { algorithm: "pbkdf2", salt: "AAAA", N: 16384, r: 8, p: 1 }, + data: `${Buffer.alloc(12).toString("base64")}.${Buffer.alloc(16).toString("base64")}.${Buffer.alloc(8).toString("base64")}`, + }, + 'unsupported kdf "pbkdf2"', + ], ])("reports unreadable for %s", async (_label, envelope, detail) => { await fs.writeFile(filePath(), JSON.stringify(envelope), "utf-8"); const store = new FileSecretStore({ filePath: filePath() }); @@ -946,6 +990,19 @@ describe("readOnDiskEncryption rejects an envelope it could not open", () => { }); }); +describe("readOnDiskEncryption accepts a real envelope", () => { + it("still reports encrypted for a file this build actually wrote", async () => { + // The negative cases above are only meaningful next to this: tightening + // the validation must not start rejecting the files we produce. + const store = new FileSecretStore({ + filePath: filePath(), + passphrase: "hunter2", + }); + await store.set("srv", "env:A", "1"); + expect(await store.readOnDiskEncryption()).toEqual({ state: "encrypted" }); + }); +}); + describe("concurrent writers (no lock, optimistic verify-and-retry)", () => { // The lock these replace was removed after three review rounds each found // a real race in it; the last needed a compare-and-swap on a directory @@ -1017,6 +1074,15 @@ describe("concurrent writers (no lock, optimistic verify-and-retry)", () => { // queue and writing through one from inside a queued section would // deadlock on it. const clobberAfterEveryWrite = (store: FileSecretStore): void => { + // Double cast, justified per the repo's TypeScript rules. The property + // reached is `private`, so no single cast expresses it. What makes it + // safe is that the asserted shape is `writeMap`'s real signature: the + // replacement is checked against it, so a rename or a signature change + // fails this line rather than silently leaving the stub detached and the + // test passing for the wrong reason. The alternative — a production + // injection seam — would add API surface existing only for this test, + // and would let the real write path drift from the tested one, which is + // precisely what this test exists to catch. const inner = store as unknown as { writeMap: (m: Record<string, string>) => Promise<void>; }; diff --git a/core/auth/node/file-secret-store.ts b/core/auth/node/file-secret-store.ts index fef8d424..4a1cb148 100644 --- a/core/auth/node/file-secret-store.ts +++ b/core/auth/node/file-secret-store.ts @@ -94,6 +94,8 @@ const FORMAT_VERSION = 1; const CIPHER = "aes-256-gcm"; const KEY_BYTES = 32; const IV_BYTES = 12; +/** AES-GCM authentication tag length, in bytes. */ +const GCM_TAG_BYTES = 16; /** scrypt defaults. `N` is the only one that meaningfully costs time; 2^14 * derives in a few milliseconds, which is invisible next to the file I/O it * accompanies and is derived once per read/write rather than per secret. */ @@ -235,6 +237,48 @@ export type SecretFileEncryptionState = | { state: "encrypted" } | { state: "unreadable"; detail: string }; +/** A positive, finite integer? */ +const isPositiveInt = (v: unknown): v is number => + typeof v === "number" && Number.isInteger(v) && v > 0; + +/** + * Why this encrypted envelope could not be opened, or `null` if it can. + * + * Everything `readMap` needs before it reaches `createDecipheriv`, checked + * in the same order it would fail: the parts, then their sizes, then the + * derivation parameters. `scrypt` validates its own cost parameters + * *synchronously*, so `N: 3` throws out of the derivation rather than + * arriving as a decrypt failure — which is why they are checked here rather + * than left to the cipher. + */ +function encryptedEnvelopeProblem(parsed: SecretFile): string | null { + if (!parsed.kdf || typeof parsed.data !== "string") { + return "encrypted envelope is missing its kdf or data"; + } + const parts = decodeParts(parsed.data); + if (!parts) return "encrypted payload is not iv.tag.ciphertext"; + if (parts.iv.length !== IV_BYTES) { + return `initialization vector is ${parts.iv.length} bytes, expected ${IV_BYTES}`; + } + if (parts.tag.length !== GCM_TAG_BYTES) { + return `authentication tag is ${parts.tag.length} bytes, expected ${GCM_TAG_BYTES}`; + } + if (parts.body.length === 0) return "ciphertext is empty"; + const { algorithm, salt, N, r, p } = parsed.kdf; + if (algorithm !== "scrypt") return `unsupported kdf "${String(algorithm)}"`; + if (typeof salt !== "string" || Buffer.from(salt, "base64").length === 0) { + return "kdf salt is missing or not base64"; + } + // `N` must be a power of two greater than 1; node rejects anything else. + if (!isPositiveInt(N) || N < 2 || (N & (N - 1)) !== 0) { + return `kdf N is ${String(N)}, which is not a power of two above 1`; + } + if (!isPositiveInt(r) || !isPositiveInt(p)) { + return `kdf r/p are ${String(r)}/${String(p)}, which are not positive integers`; + } + return null; +} + export interface FileSecretStoreOptions { /** Absolute path of the secrets file. */ filePath: string; @@ -313,23 +357,14 @@ export class FileSecretStore implements SecretStore { } if (parsed.encryption === "none") return { state: "plaintext" }; if (parsed.encryption === CIPHER) { - // Naming the cipher is not the same as being openable. An envelope - // with no `kdf`/`data` — or a `data` that is not the three-part - // payload — is refused by `readMap`, so reporting it as "encrypted" - // gives the quiet, reassuring footer for a file whose very next save - // is guaranteed to fail. Check the shape the decryptor requires. - if (!parsed.kdf || typeof parsed.data !== "string") { - return { - state: "unreadable", - detail: "encrypted envelope is missing its kdf or data", - }; - } - if (!decodeParts(parsed.data)) { - return { - state: "unreadable", - detail: "encrypted payload is not iv.tag.ciphertext", - }; - } + // Naming the cipher is not the same as being openable, and a partial + // check is its own trap: three dot-separated strings satisfy + // `decodeParts` while an empty tag, a 4-byte IV or `N: 3` still make + // every decrypt throw. Reporting "encrypted" then gives the quiet, + // reassuring footer to a file whose very next save is guaranteed to + // fail, which is the one thing this state exists to prevent. + const problem = encryptedEnvelopeProblem(parsed); + if (problem) return { state: "unreadable", detail: problem }; return { state: "encrypted" }; } return { @@ -509,23 +544,32 @@ export class FileSecretStore implements SecretStore { * someone wrote between our write and our read — so re-apply onto what * they left and try again. * - * The comparison is over the **whole map**, not just the entry we touched, - * and that is the entire reason this works. Checking only our own key - * passes in precisely the case that loses data: our entry is present, and - * it is the *other* writer's that is gone. Trace two writers A and B from - * the same `M0` — A writes `MA`, B writes `MB` and clobbers A's entry. B - * verifies and sees `M2 === MB`, correctly: B did nothing wrong. A - * verifies, sees `M2 !== MA`, re-applies onto `MB`, and writes again. Both - * entries survive, and the writer that was clobbered is the one that - * repairs it. + * The comparison is over the **whole map**, not just the entry we touched. + * Checking only our own key would pass in precisely the case that loses + * data: our entry is present, and it is the *other* writer's that is gone. + * When the verify does fire, the writer that was clobbered is the one that + * repairs it — A writes `MA`, B writes `MB` over it, B verifies `MB` + * correctly, A verifies, sees `M2 !== MA`, re-applies onto `MB`. * - * **The residual window, stated plainly rather than left to be - * rediscovered:** a process that crashes between its write and its - * verifying read cannot notice it was clobbered, and nothing else will. - * That is strictly narrower than the lock's failure — which lost updates - * with every participant alive and well — and unlike the lock it needs no - * primitive Node lacks. It is not mutual exclusion and this file does not - * claim to provide it. + * **This is not mutual exclusion, and the gap is wider than a crash.** + * The verify only catches a clobber that has already landed. Order the + * same two writers as write-A, verify-A, write-B, verify-B and both + * succeed while A's entry is gone: A's verify ran before B's write, so + * there was nothing yet to see, and B did nothing wrong. Nothing detects + * it afterwards. A crash between write and verify is one instance of the + * same shape, not the whole of it — an earlier version of this comment + * said otherwise, which understated it. + * + * What that buys, stated without overselling: two processes must write the + * same file within the window between one's write and its read-back, and + * the loss is one secret that reported success. The lock this replaced + * lost updates in a *wider* set of interleavings, with every participant + * alive, and could not be closed without a primitive Node does not expose + * (`renameat2(RENAME_EXCHANGE)`); this costs ~220 fewer lines and converges + * in every interleaving where the clobber lands before the verify. If the + * residual matters for a deployment, the answer is a real lock — an + * OS-backed one from a dedicated library — not another hand-rolled + * election. * * Reads deliberately do not participate: `writeStoreFile` is atomic * (write-temp-then-rename), so a reader sees either the old file or the diff --git a/core/auth/secret-storage-info.ts b/core/auth/secret-storage-info.ts index 847d547f..ce438dfc 100644 --- a/core/auth/secret-storage-info.ts +++ b/core/auth/secret-storage-info.ts @@ -169,6 +169,15 @@ export function secretStorageCaveat( // kind of wrong: a reader of an *encrypted* file gets ciphertext, not // secrets. Saying otherwise trains people to discount the warning on the // occasion it is literally true. + // Three cases, not two. With `encryptionUnknown` set, `plaintext` is + // absent — and `!== false` then lands on the plaintext wording, which + // asserts that a reader gets the secrets. The envelope may well hold + // ciphertext; we simply could not tell. Claiming the worse of two + // unknowns is still claiming something we did not establish, which is + // the failure this descriptor exists to avoid. + if (info.encryptionUnknown !== undefined) { + return `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and whether that exposes the secrets depends on encryption this build could not determine (${info.encryptionUnknown}).`; + } return info.plaintext === false ? `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and the passphrase is then the only thing protecting its contents.` : `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`; diff --git a/specification/v2_servers_file.md b/specification/v2_servers_file.md index 6e019c8f..cc51c295 100644 --- a/specification/v2_servers_file.md +++ b/specification/v2_servers_file.md @@ -2,7 +2,8 @@ ### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | V2 Tech Stack | [V2 UX](v2_ux.md) | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) -#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage +#### [Web Client](v2_web_client.md) | [CLI, TUI, Launcher](v2_cli_tui_launcher.md) | [Server](v2_server.md) | Storage + ##### [Overview](v2_storage.md) | Server List File ## Summary @@ -38,12 +39,12 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil "filesystem-server-default": { "type": "stdio", "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], }, "everything-server-default": { "type": "stdio", "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-everything"] + "args": ["-y", "@modelcontextprotocol/server-everything"], }, "acme-api": { "type": "streamable-http", @@ -54,16 +55,16 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil // for the full contract; the in-memory + wire shape keeps pair-array // headers and flat oauth* fields for the form's controlled-component // editing. - "headers": { "X-Tenant": "acme" }, + "headers": { "X-Tenant": "acme" }, "metadata": [{ "key": "trace", "value": "abc" }], "connectionTimeout": 30000, - "requestTimeout": 60000, + "requestTimeout": 60000, "oauth": { "clientId": "client-abc", - "scopes": "read:tools write:tools" - } - } - } + "scopes": "read:tools write:tools", + }, + }, + }, } ``` @@ -81,12 +82,12 @@ If the file does not exist when the backend boots, write a file containing the t ### Reused -| Concern | File | What we reuse | -|---|---|---| -| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` | -| `mcp.json` parsing + type normalization | `core/mcp/node/config.ts` | `loadMcpServersConfig` (already used by the CLI/TUI runner code); `normalizeServerType` needs to be exported | -| Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes | -| Auth'd fetch from browser | wired via `getAuthToken()` in `clients/web/src/App.tsx:84` | `useServers` will call the backend with `x-mcp-remote-auth: Bearer <token>` | +| Concern | File | What we reuse | +| ------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Atomic R/W + ENOENT handling + 0o600 + `mkdir -p` | `core/storage/store-io.ts` | `readStoreFile`, `writeStoreFile`, `deleteStoreFile`, `parseStore`, `serializeStore` | +| `mcp.json` parsing + type normalization | `core/mcp/node/config.ts` | `loadMcpServersConfig` (already used by the CLI/TUI runner code); `normalizeServerType` needs to be exported | +| Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes | +| Auth'd fetch from browser | wired via `getAuthToken()` in `clients/web/src/App.tsx:84` | `useServers` will call the backend with `x-mcp-remote-auth: Bearer <token>` | ### Why not a `{ state, version }` envelope for `mcp.json` @@ -123,16 +124,17 @@ Also re-export `normalizeServerType` from `core/mcp/node/config.ts` (or move it Add granular endpoints (mirror of `/api/storage/:storeId`, but specialized so the UI can do per-row mutations without read-modify-write across tabs): -| Method | Path | Body | Response | -|---|---|---|---| -| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent | -| `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists | -| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (rewrites `mcpServers` key; migrates keychain secrets — see §Secret storage) | -| `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) | +| Method | Path | Body | Response | +| -------- | ------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent | +| `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists | +| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (rewrites `mcpServers` key; migrates keychain secrets — see §Secret storage) | +| `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) | `id` is validated with `validateStoreId` (same alphanum+hyphen+underscore rule as store IDs — prevents anyone slipping `..` into the key). All routes serialize through the same atomic `writeStoreFile`, so concurrent writes are well-defined (last writer wins per-write; granularity reduces blast radius). `RemoteServerOptions` gains: + ```ts /** Optional path for the user's server list file. Default: ~/.mcp-inspector/mcp.json */ mcpConfigPath?: string; @@ -149,12 +151,16 @@ export interface UseServersResult { error: string | undefined; refresh: () => Promise<void>; addServer: (id: string, config: MCPServerConfig) => Promise<void>; - updateServer: (originalId: string, newId: string, config: MCPServerConfig) => Promise<void>; + updateServer: ( + originalId: string, + newId: string, + config: MCPServerConfig, + ) => Promise<void>; removeServer: (id: string) => Promise<void>; } export function useServers(opts: { - baseUrl: string; // window.location.origin by default in callers + baseUrl: string; // window.location.origin by default in callers authToken: string | undefined; }): UseServersResult; ``` @@ -171,7 +177,7 @@ Fetches on mount via `fetch(`${baseUrl}/api/servers`, ...)` with the auth header ### UI surfaces -The `InspectorView` prop interface already declares `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` / `onServerImportConfig` / `onServerImportJson`. The dialogs themselves are TBD — out of scope for *this* spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine `Modal` + `TextInput` + transport-specific fields. Follow `clients/web/src/components/...` conventions (subcomponent constants via `.withProps()`, theme variants for styling — per `AGENTS.md`'s React rules). +The `InspectorView` prop interface already declares `onServerAdd` / `onServerEdit` / `onServerClone` / `onServerRemove` / `onServerImportConfig` / `onServerImportJson`. The dialogs themselves are TBD — out of scope for _this_ spec is the visual design; in scope is wiring them to the new hook. If the Add/Edit dialog component does not exist yet, ship a minimal Mantine `Modal` + `TextInput` + transport-specific fields. Follow `clients/web/src/components/...` conventions (subcomponent constants via `.withProps()`, theme variants for styling — per `AGENTS.md`'s React rules). `onServerImportConfig` / `onServerImportJson` map naturally to "paste a full `mcpServers` block" and "upload an `mcp.json` file"; both become bulk `POST /api/servers` calls in a loop (or a single `PUT /api/servers` that we add later). Defer until basic add/edit/remove is working. @@ -199,7 +205,7 @@ Per `AGENTS.md`'s "test new or modified code" rule plus the UI-changes guidance: ## Risks -- **Concurrent writes from multiple browser tabs.** Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention is last-write-wins, which is fine for a config the user is editing manually. We do *not* add file locking; the cost outweighs the rare case. +- **Concurrent writes from multiple browser tabs.** Granular endpoints reduce the surface (per-row, not whole-file). Same-row contention on `mcp.json` is last-write-wins, which is fine for a config the user is editing manually. We do _not_ lock `mcp.json`; the cost outweighs the rare case. The _secrets_ file is a separate question with a separate answer — see [Secret storage](#secret-storage-1356) — because losing a write there loses a credential rather than a preference. - **User edits the file while the browser is open.** Browser holds a stale list until the user hits Refresh on the Servers screen (the `refresh` returned by the hook). Acceptable for v1; auto-watching the file is a possible follow-up but `fs.watch` semantics across OSes are a long tail of bugs. - **Schema drift with Claude Desktop / Cursor.** They occasionally add fields (e.g. Claude Desktop's `disabled`). `loadMcpServersConfig` currently does `JSON.parse(...) as MCPConfig` — extra fields survive the round-trip as long as we don't filter them. The converters in `serverList.ts` should preserve unknown fields on `MCPServerConfig` rather than copying a fixed allow-list. - **Migration from `SEED_SERVERS`.** Existing dev users have no file. First boot writes one — they won't notice. No code path persists the in-memory `useState` list today, so nothing to migrate. @@ -218,17 +224,17 @@ Each server entry may carry these Inspector-extension fields at the top level: "my-server": { "type": "streamable-http", "url": "https://example.com/mcp", - "headers": { "Authorization": "Bearer xxx" }, + "headers": { "Authorization": "Bearer xxx" }, "metadata": [{ "key": "tenant", "value": "acme" }], "connectionTimeout": 30000, - "requestTimeout": 60000, + "requestTimeout": 60000, "oauth": { - "clientId": "...", + "clientId": "...", "clientSecret": "...", - "scopes": "read:tools write:tools" - } - } - } + "scopes": "read:tools write:tools", + }, + }, + }, } ``` @@ -242,14 +248,24 @@ Each server entry may carry these Inspector-extension fields at the top level: - `requestTimeout` → `InspectorClientOptions.timeout`. - `connectionTimeout` → `Promise.race` wrapper around `InspectorClient.connect()` in the web client. - `oauth.clientId` / `oauth.clientSecret` / `oauth.scopes` → pre-seeded OAuth client credentials via `InspectorClientOptions.oauth` (the disk-side `oauth` object is lifted into the flat `oauthClientId` / etc. fields on `InspectorServerSettings` for the form). -- **First-connect contract**: settings apply on the *first* outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`. -- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface; `KeyringSecretStore` is the production impl and `InMemorySecretStore` is the test double the integration suite injects via `RemoteServerOptions.secretStore`. +- **First-connect contract**: settings apply on the _first_ outbound request after the entry loads from disk — no need to open the settings form. The browser sends `settings` to the backend in the `/api/mcp/connect` body; the backend reads it from `RemoteConnectRequest` and threads it into `createTransportNode`. +- **Secret storage (#1356)**: `oauth.clientSecret` and stdio `env` values are persisted in the OS keychain (macOS Keychain Services / Windows Credential Manager / Linux libsecret via `@napi-rs/keyring`), keyed by `(serverId, field)` under the service name `mcp-inspector`. Field names: `oauth-client-secret`, `env:<KEY>` (one per stdio env variable). The on-disk `mcp.json` is stripped of these values — `oauth.clientSecret` is omitted entirely, stdio env keys are preserved with empty-string placeholders (`"env": { "API_KEY": "" }`) so the file still documents the env interface the server expects. The wire shape returned by `GET /api/servers` is unchanged from before #1356: the handler rehydrates values from the keychain so browser code sees the same JSON it has always seen. **TUI/CLI rehydration:** the shared `loadServerEntries()` path (`core/mcp/node/servers.ts`) calls `rehydrateMcpConfigFromKeychain()` (`core/mcp/node/server-secrets.ts`) after reading `mcp.json`, so runner clients see the same effective OAuth client secrets and stdio env values as the web catalog list. This path is read-only — it does not migrate plaintext secrets into the keychain (that remains the web `GET /api/servers` migration sweep). The keychain interactions live in `core/auth/node/secret-store.ts` behind a `SecretStore` interface. As of #1950 there are **three** implementations — `KeyringSecretStore`, `FileSecretStore`, `InMemorySecretStore` — chosen per run by `core/auth/node/secret-store-selection.ts`; the integration suite still injects one directly via `RemoteServerOptions.secretStore`. - **Migration**: on every `GET /api/servers`, the handler walks the freshly-read config and, for any entry that still carries plaintext secrets (older Inspector builds, hand-edited files, files imported from another tool), lifts each value into the keychain and rewrites the file with the stripped shape. The migration is idempotent — when the keychain already holds a value for `(serverId, field)`, the keychain wins and the disk plaintext is dropped unread. After the rewrite the disk file no longer contains the secret material. - - **Linux without libsecret**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs. + - **Linux without libsecret**: every `SecretStore` shares one _tolerance contract_ — `get` returns `null`, the destructive operations silently no-op, and only `set` hard-fails (with a `SecretStoreUnavailableError`, translated to a `503`). So no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) work normally on a box with no reachable keychain. **Since #1950 that is no longer the end of the story**: a host without a keychain now falls back to a file or in-memory store rather than being unable to persist a secret at all — see [Secret store selection (#1950)](#secret-store-selection-1950). - **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action. - **Write ordering on POST/PUT**: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a `set` failure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at `409`, and no premature deletion of an obsolete field whose disk write later fails. - **Id rename (`PUT` with new `id`)**: secrets are keyed by **server id** (the `mcpServers` map key), not URL. On rename the handler reads existing keychain entries for the **original** id (`expectedSecretFields` + `readKeychainEntriesFor`), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the **new** on-disk entry still expects (so removed stdio env keys are not carried over), writes under the **new** id, updates disk, then `deleteAllForServer(originalId)`. This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered by `servers-route.test.ts` (`PUT rename moves … when it exists only in the keychain` for OAuth and stdio env). - **Out of scope for this PR**: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked `mcp.json` and is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately. +- **Secret store selection (#1950)**: #1356 assumed a keychain. On a host without one — the published container (no D-Bus session, #1848), Android/Termux (no prebuilt binary, #1905), a minimal Linux install — `set` hard-failed, so those users could not persist an OAuth client secret or a stdio `env:` value **at all**. #1950 adds the two missing implementations plus the policy that picks one, and the surfaces that say which was picked. + - **Selection**: `MCP_INSPECTOR_SECRET_STORE=keyring|file|memory` wins outright. Otherwise the keychain is _probed_ — an `AsyncEntry` construction plus a real read, because the container that motivated #1848 imports the package fine and only fails when it reaches for a Secret Service that isn't there. A read and not a write, deliberately: probing by writing would deposit a value in the user's login keyring at every startup for a store they may never use. If the probe fails the fallback is **`memory`** in a container whose secrets directory is not on a mount, and **`file`** otherwise — so mounting the volume the README already recommends for the catalog flips the same run to durable storage with no configuration, and an unmounted container gets the honest answer rather than a file `docker run --rm` will discard. Resolution is cached per process so the startup banner, `GET /api/config`, and the store doing the writing cannot disagree. + - **`FileSecretStore` at rest**: one JSON document at `~/.mcp-inspector/secrets.json` (or `MCP_INSPECTOR_SECRET_FILE`, else under `MCP_STORAGE_DIR`), written `0600` and re-tightened at startup. With `MCP_INSPECTOR_SECRET_KEY` set it is AES-256-GCM with a scrypt-derived key and a per-write random salt; without it the values are in the clear, and _that_ is what the loud banner and the warning-toned modal footer are about. The **whole map is encrypted as a unit**, not value-by-value, so the account names (`serverId:field`) are hidden too — a per-value scheme would leave a readable index of which servers you hold a client secret for. Upgrading is lazy: setting the passphrase later re-encrypts on the next write rather than rewriting the file during a run that may never touch a secret, and the descriptor reports `pendingEncryption` until it does. A file that can no longer be decrypted reads as empty but **refuses to be written**, because replacing a file of still-valid secrets to satisfy an additive request destroys data. + - **Concurrency, and what it does not promise**: within a process, mutations are serialized per **resolved file path** (not per store instance — two `FileSecretStore`s on one file are ordinary, since the resolved store holds one and the keychain hand-off builds another). Across processes there is **no lock**. An earlier iteration had a `mkdir` election with an owner stamp, heartbeat and stale-takeover; three review rounds each found a real race, and the last is not closable with what Node exposes — claiming a stale lock needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`). It was replaced with optimistic concurrency: read `M0`, apply, write `M1`, read back `M2`, and re-apply onto whatever a concurrent writer left if they differ, bounded, with `set` throwing on non-convergence rather than returning as though the value were saved. The comparison is over the **whole map** — checking only your own entry passes in exactly the case that loses data, because yours is present and the other writer's is gone. **This is not mutual exclusion.** The verify only catches a clobber that has already landed, so ordering the two writers write-A / verify-A / write-B / verify-B leaves both reporting success with A's entry gone — A's verify ran before there was anything to see. A crash between write and verify is one instance of that shape, not the whole of it. The window is narrower than the lock's (which lost updates across a wider set of interleavings, with every participant alive) and needs no primitive Node lacks, but it is a real residual and is documented as one. If a deployment needs the guarantee, the answer is an OS-backed lock from a dedicated library, not another hand-rolled election. + - **Strict reads (`getStrict`)**: `get` is tolerant by contract, and that tolerance is wrong for exactly one caller — a migration that treats `null` as proof of absence and then _writes_. A transient read failure would look like "nothing there", and the write would replace a newer stored value with an older on-disk copy, inverting the keychain-wins rule the migration is built on. `getStrict` throws instead, and both plaintext migrations plus the keychain hand-off use it. Two traps this hit on the way in, both worth remembering: the seam must be forwarded by `DeferredSecretStore` (the store production actually uses, so without forwarding the strictness existed only in tests that injected a concrete store), and it must be implemented by _every_ store rather than falling back to `get` for the one it was introduced for. + - **Bulk reads (`getMany`)**: rehydration asks field by field, and `FileSecretStore.get` reads and decrypts the entire file per call. scrypt at `N=16384` measures ~23ms, so a 20-secret encrypted catalog spent ~450ms of pure key derivation on every `GET /api/servers` — a visible stall rather than a micro-optimization. `getMany` decrypts once per server; stores for which per-field reads are already cheap fall back to parallel `get`. + - **Durability gate on migration**: the plaintext-stripping migrations only delete the disk copy once the value is somewhere that outlives the process (`isDurable`). Against a session-scoped store, stripping would trade a secret that survives restarts for one that dies with the process — and it runs on an ordinary `GET`, so merely opening the app would destroy it. + - **Hand-off when a keychain appears**: install libsecret after storing secrets in a file, and the next run selects the keychain and stops seeing them — still on disk, read by nothing, nothing visibly broken. `absorbFileSecretsIntoKeyring` copies them over on that run, under the same keychain-wins rule, and deletes the file **only** on complete success. Since the store takes no lock, the delete is conditional: the file is re-read immediately beforehand and compared against what was copied, and the delete is declined when they differ, because another process may have added an entry that was never copied. + - **Surfacing it**: the active store rides `GET /api/config` as a `secretStorage` descriptor and is stated in a permanent footer at the bottom of the Client Settings and Server Settings modals — the only two dialogs that accept a secret. A startup banner is seen once by whoever started the process; a toast is seen once; a dismissible banner is by design the thing a user dismisses before doing the work it describes. The descriptor is re-derived per request rather than cached, because `plaintext`/`pendingEncryption` describe bytes this very process changes. + - **Hard-cutover legacy behavior (per #1358 decision 4)**: files written by the one pre-#1358 build of v2/main have a nested `settings` block. `normalizeMcpServers` drops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change. - **UI**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ id, settings }` — the route preserves the on-disk transport config inside its write lock. Conversely, `useServers.updateServer` (driven by the basic-config modal) issues a config-only PUT with `{ id, config }` and the route preserves the on-disk settings fields. Edits in either modal cannot silently wipe the other half. - **Save cadence**: the form fires `onSettingsChange` on every keystroke. `App.tsx` debounces 300 ms and flushes on modal close so a burst of edits coalesces into a single PUT. If the close-flush PUT fails (network hiccup, server 500), a red `@mantine/notifications` toast surfaces the failure — the modal has already closed so a silent failure would leave the user thinking the last edits saved. From 08fc20a22d827bcf8894fe3df5a40f10bde650fa Mon Sep 17 00:00:00 2001 From: cliffhall <cliff@futurescale.com> Date: Sat, 22 Aug 2026 19:05:21 -0400 Subject: [PATCH 247/941] feat(apps): negotiated app-rendered form elicitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a server hand a form `elicitation/create` to an MCP App and return the app's ordinary `ElicitResult`, with the native elicitation form as the fallback on every failure. No second extension, no custom method, and no custom result shape: the only new wire surface is a nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` extension on each side, plus `_meta.ui.resourceUri` on the request. App rendering is selected only when all four gates hold — client `elicitation.form`, client MCP Apps MIME type, the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://` URI on the request. Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer: supplying `InspectorClientOptions.appElicitation` is what opts a client in, so CLI and TUI keep advertising the MIME type without ever claiming they can resolve an elicitation through an app. Routing lives in the one funnel both entry points already use (`enqueuePendingElicitation`), so the inbound handler and the MRTR driver cannot diverge. Ownership is request-scoped — a per-request id keys the renderer, iframe and bridge — so two concurrent elicitations can never resolve through each other's bridges. An explicit `decline`/`cancel` is a completed elicitation and goes back to the server; everything else (absent or malformed metadata, resource/sandbox/bridge failure, an app with no elicitation capability, a timeout, an invalid result or one that fails the requested schema) falls back. The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion once a release containing it ships. One consequence needed its own seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that strips `elicitation`, so an app that correctly advertises it would look like one that did not, silently turning every negotiated elicitation into a fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and prefers the bridge's own value once it carries the key. `AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so the same renderer and bridge factory serve an App tool and an elicitation without either faking the other's shape. Adds the public fixture (`app_choose_option` + a self-contained `ui://demo/choose-option.html` app covering accept/decline/cancel), its two showcase configs, and `smoke:web:elicit`, which drives the negotiated path end to end in headless Chromium and then the same tool against a server that never advertised the capability, asserting the native form takes it. Closes #1854 Signed-off-by: cliffhall <cliff@futurescale.com> --- AGENTS.md | 12 +- README.md | 22 +- clients/web/README.md | 9 + clients/web/src/App.tsx | 74 ++- .../AppElicitationHost.stories.tsx | 104 +++++ .../AppElicitationHost.test.tsx | 411 +++++++++++++++++ .../AppElicitation/AppElicitationHost.tsx | 270 +++++++++++ .../AppRenderer/AppRenderer.stories.tsx | 2 +- .../elements/AppRenderer/AppRenderer.test.tsx | 222 ++++++--- .../elements/AppRenderer/AppRenderer.tsx | 53 ++- .../AppRenderer/appCapabilities.test.ts | 94 ++++ .../elements/AppRenderer/appCapabilities.ts | 67 +++ .../AppRenderer/appRenderSource.test.ts | 86 ++++ .../elements/AppRenderer/appRenderSource.ts | 49 ++ .../createAppBridgeFactory.test.ts | 32 +- .../AppRenderer/createAppBridgeFactory.ts | 50 +- .../AppRenderer/requestAppElicitation.test.ts | 91 ++++ .../AppRenderer/requestAppElicitation.ts | 59 +++ .../screens/AppsScreen/AppsScreen.tsx | 2 +- .../src/lib/appElicitationController.test.ts | 119 +++++ .../web/src/lib/appElicitationController.ts | 99 ++++ .../src/test/core/mcp/appElicitation.test.ts | 274 +++++++++++ .../web/src/test/core/mcp/extensions.test.ts | 38 ++ .../inspectorClient-app-elicitation.test.ts | 431 ++++++++++++++++++ .../integration/mcp/appElicitation.test.ts | 168 +++++++ core/mcp/appElicitation.ts | 238 ++++++++++ core/mcp/extensions.ts | 21 + core/mcp/inspectorClient.ts | 138 +++++- core/mcp/types.ts | 16 + package.json | 3 +- scripts/smoke-web-elicitation.mjs | 318 +++++++++++++ .../configs/app-elicitation-http.json | 13 + .../configs/app-elicitation-native-http.json | 12 + test-servers/src/composable-test-server.ts | 27 ++ test-servers/src/load-config.ts | 4 + test-servers/src/preset-registry.ts | 6 + test-servers/src/resolve-config.ts | 1 + test-servers/src/test-server-fixtures.ts | 176 +++++++ 38 files changed, 3718 insertions(+), 93 deletions(-) create mode 100644 clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx create mode 100644 clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx create mode 100644 clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx create mode 100644 clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts create mode 100644 clients/web/src/components/elements/AppRenderer/appCapabilities.ts create mode 100644 clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts create mode 100644 clients/web/src/components/elements/AppRenderer/appRenderSource.ts create mode 100644 clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts create mode 100644 clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts create mode 100644 clients/web/src/lib/appElicitationController.test.ts create mode 100644 clients/web/src/lib/appElicitationController.ts create mode 100644 clients/web/src/test/core/mcp/appElicitation.test.ts create mode 100644 clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts create mode 100644 clients/web/src/test/integration/mcp/appElicitation.test.ts create mode 100644 core/mcp/appElicitation.ts create mode 100644 scripts/smoke-web-elicitation.mjs create mode 100644 test-servers/configs/app-elicitation-http.json create mode 100644 test-servers/configs/app-elicitation-native-http.json diff --git a/AGENTS.md b/AGENTS.md index 0771175e..20b6a428 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,17 @@ v2/main/ │ │ # listSalvage.ts: per-item salvage for list results — │ │ # keeps the valid entries when one is non-conforming │ │ # instead of losing the whole list, and owns the -│ │ # shared isClientDecodeRejection predicate — #1909) +│ │ # shared isClientDecodeRejection predicate — #1909; +│ │ # appElicitation.ts: app-rendered form elicitation +│ │ # (#1854) — the four negotiation gates, the +│ │ # `_meta.ui.resourceUri` reader/validator, and the +│ │ # result validator. Mirrors ext-apps#733 / SEP-3118 +│ │ # because the released ext-apps (1.7.5) predates it; +│ │ # the host-side renderer is supplied by the CLIENT +│ │ # (`InspectorClientOptions.appElicitation`), and +│ │ # supplying one is what advertises the nested +│ │ # `elicitation` setting — so web opts in and +│ │ # cli/tui, which cannot host an App, do not) │ │ ├── import/ # Config import strategies (#1348): client-config parsers │ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry │ │ │ # server.json parser, strategy registry + well-known diff --git a/README.md b/README.md index 2c420c00..8ae6e02c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa | Config | Demonstrates | Issue | | ----------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | | `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) | +| `app-elicitation-http.json` **(legacy era)** | An MCP App rendering a form elicitation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) | | `modern-mrtr-http.json` | A single MRTR round-trip | — | | `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) | | `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) | @@ -162,6 +163,25 @@ Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App** For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md). +#### App-rendered form elicitations + +`app-elicitation-http.json` serves `app_choose_option` alongside the `choose_option_app` UI resource (`ui://demo/choose-option.html`). The tool sends a completely ordinary form `elicitation/create` — the only thing added is `_meta.ui.resourceUri` naming that app. Plain streamable-HTTP; connect with the **default (legacy)** protocol era. + +Run `app_choose_option` from the Tools tab. The server's app renders in a modal instead of the built-in elicitation form, and clicking **Option A**, **Decline**, or **Cancel** returns the standard `ElicitResult` straight to the server, which echoes it into the tool result. + +App rendering is selected only when **all four** conditions hold ([#1854](https://github.com/modelcontextprotocol/inspector/issues/1854), per [ext-apps#733](https://github.com/modelcontextprotocol/ext-apps/pull/733) / SEP-3118): + +1. the client advertises `elicitation.form`; +2. the client advertises `extensions["io.modelcontextprotocol/ui"].mimeTypes` including `text/html;profile=mcp-app`; +3. **both** the client and the server advertise the nested `elicitation` setting on that same extension; +4. the request carries a valid absolute `ui://` URI in `_meta.ui.resourceUri`. + +Only the **web** client advertises the nested client-side setting, and only because it has a sandbox renderer to back it; the CLI and TUI advertise the MIME type (they know what an App is) but never claim they can resolve an elicitation through one, so the same server falls back to their native prompts. Turning **Server Settings → Advertised Extensions → MCP Apps UI** off, or turning form elicitation off, removes the claim on web too. + +Everything else falls back to the built-in elicitation form, by design: metadata that is absent or not an absolute `ui://` URI, a resource that fails to load, a sandbox or bridge that fails to initialize, an app that did not advertise `elicitation`, a request that times out, and any result that is not a valid `ElicitResult` for the requested schema. An explicit `decline` or `cancel` is **not** a fallback — it is a completed elicitation and goes back to the server as-is. + +> The Inspector speaks the ext-apps#733 wire protocol but does not yet consume its helpers: the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion in favour of the package's own exports once a release containing it ships. + #### MRTR `modern-mrtr-http.json` serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg. Its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real round-trip: `input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`. @@ -343,7 +363,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `npm run validate` | Runs the three durable guards first — `verify:format-coverage` (every tracked source file is format-gated), `verify:typecheck-coverage` (every one lands in a tsconfig project), `verify:dep-lockstep` (no dependency reaching one `tsc` program from two installs skews across them) — then `test:scripts` (the guards' own parser unit tests), then `validate:core` (the shared `core/` `format:check` + `lint` gate), then per client: `format:check` + `lint` + **`typecheck`** (cli/tui/launcher; web typechecks via `tsc -b` inside its `build`) + `build` + fast unit tests. The quick inner-loop check. | | `npm run coverage` | The **per-file ≥90% gate** (lines/statements/functions/branches) under v8 instrumentation, per client. CI-enforced. For web this also runs the integration project and covers the shared `core/` runtime (including `core/json` and `core/client`). | -| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. | +| `npm run smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus two headless-Chromium smokes: a boot smoke that runs the prod web bundle and asserts a clean first render (no uncaught error — sync exception or unhandled rejection, how a Node built-in reaching the browser bundle manifests), and an **MCP Apps** smoke (`smoke:web:app`) that drives connect → open app → `data-app-status="ready"` against a composable App server, covering the sandbox proxy and UI-protocol bridge. A third (`smoke:web:elicit`) drives an **app-rendered elicitation** end to end — call the tool, answer inside the sandboxed app, see the app's `ElicitResult` reach the server — and then the same tool against a server that never advertised the capability, which must fall back to the native elicitation form. | | `npm run verify:build-gate` | Runs a real `vite build` with a Node built-in forced into the browser graph and asserts the build **fails** via the #1769 gate (which turns Vite's browser-externalization warning into a hard error). Guards against the warning phrasing drifting in a Vite bump and silently disabling the gate. Part of `npm run ci`. | | `npm run verify:format-coverage` | Parses the `format:check` globs out of every `package.json` (only those reachable from `validate`), enumerates all tracked source files, and **fails** listing any not covered by a glob — the durable guard for the "every first-party source file is format-gated" invariant (#1792). Runs first in `validate`. | | `npm run test:scripts` | Table-driven unit tests (`node --test`) for the guard's own pure parsers (`scripts/lib/npm-scripts.mjs`, `scripts/lib/tsc-program.mjs` + the exported helpers of `verify-typecheck-coverage.mjs` and `verify-dep-lockstep.mjs`), one case per rule they encode, plus `scripts/lib/resolve-node-bin.test.mjs` — the cross-platform bin resolver (#1939), pinned against the real `bin`/`exports` shapes of the packages the scripts actually spawn. Runs in `validate` — and `verify:typecheck-coverage` guards *this* gate in turn (reachable from `validate`, non-empty test set, every test file matched by the `test:scripts` glob), since `node --test` silently skips a file its glob misses and still exits 0. | diff --git a/clients/web/README.md b/clients/web/README.md index 01873854..19f77385 100644 --- a/clients/web/README.md +++ b/clients/web/README.md @@ -85,6 +85,15 @@ The Apps screen exposes a small, stable set of `data-testid` / `data-*` attribut | `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. | | `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). | +App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair: + +| Attribute | Where | Meaning | +| --- | --- | --- | +| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. | +| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. | + +`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling). + The renderer lifecycle itself is `AppRendererStatus` (`loading` | `ready` | `error`) reported via `AppRenderer`'s `onAppStatusChange`; the screen maps it to `data-app-status`. Resource-read failures (malformed/404 UI resource) are surfaced as a toast via the bridge factory's `onResourceError`; because the app never reaches `ready` in that case, a driver times out on `data-app-status` and reads the toast. ## Deep-link auto-connect diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index db29e06b..c3531ceb 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { Anchor, Box, @@ -134,6 +141,8 @@ import { import { clearScrollMemory } from "./hooks/useScrollMemory"; import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer"; import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory"; +import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost"; +import { AppElicitationController } from "./lib/appElicitationController"; import type { LogEntryData } from "./components/elements/LogEntry/LogEntry"; import { ServerConfigModal, @@ -792,6 +801,56 @@ function App() { [inspectorClient], ); + // App-rendered form elicitations (#1854). The controller is created once and + // handed to every InspectorClient at construction — its `render` is what opts + // this client into advertising the nested MCP Apps `elicitation` capability, + // which is why only the web client (the one with a sandbox) claims it. + const appElicitationControllerRef = useRef<AppElicitationController>(null); + appElicitationControllerRef.current ??= new AppElicitationController(); + const appElicitationController = appElicitationControllerRef.current; + const appElicitations = useSyncExternalStore( + appElicitationController.subscribe, + appElicitationController.getEntries, + ); + // A SECOND factory, differing from `sandboxBridgeFactory` only in that it + // advertises `hostCapabilities.elicitation`. An App-tool frame is never handed + // an elicitation, so telling those apps otherwise would be a false claim. + const elicitationBridgeFactory = useMemo( + () => + createAppBridgeFactory({ + advertiseElicitation: true, + getClient: () => inspectorClient?.getAppRendererClient() ?? null, + readResource: async (uri) => { + if (!inspectorClient) throw new Error("No MCP client connected."); + const invocation = await inspectorClient.readResource(uri); + return invocation.result; + }, + // Unlike the Apps tab there is no persistent surface to show the + // failure on — the modal is about to be replaced by the native form — + // so the toast is the only place the user learns why. + onResourceError: (err) => { + notifications.show({ + title: "Elicitation app failed to load", + message: err.message, + color: "red", + }); + }, + }), + [inspectorClient], + ); + const handleAppElicitationSettle = useCallback( + (requestId: string, result: ElicitResult) => { + appElicitationController.settle(requestId, result); + }, + [appElicitationController], + ); + const handleAppElicitationFail = useCallback( + (requestId: string, error: Error) => { + appElicitationController.fail(requestId, error); + }, + [appElicitationController], + ); + const [managedToolsState, setManagedToolsState] = useState<ManagedToolsState | null>(null); const [managedPromptsState, setManagedPromptsState] = @@ -2410,6 +2469,11 @@ function App() { // Sampling / elicitation are on by default; keep the parameterized // options off until the UI grows the surface to render them. elicit: { form: true, url: true }, + // Web only: hands form elicitations that name a `ui://` resource to the + // MCP App the server chose, and advertises the nested MCP Apps + // `elicitation` capability that makes a server willing to send one + // (#1854). The native elicitation queue stays the fallback. + appElicitation: appElicitationController.render, // Always advertise the roots capability (even with no configured // roots) so the server can issue roots/list and receive // roots/list_changed; the configured roots are the answer to @@ -2511,6 +2575,7 @@ function App() { sessionStorageAdapter, onBeforeOAuthRedirect, clientConfig, + appElicitationController.render, ], ); @@ -4571,6 +4636,13 @@ function App() { onRefreshApps={onRefreshTools} /> </Box> + <AppElicitationHost + entries={appElicitations} + sandboxPath={sandboxUrl} + bridgeFactory={elicitationBridgeFactory} + onSettle={handleAppElicitationSettle} + onFail={handleAppElicitationFail} + /> <ServerConfigModal opened={configModal !== null} mode={configModal?.mode ?? "add"} diff --git a/clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx new file mode 100644 index 00000000..a2f653d1 --- /dev/null +++ b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ElicitRequest } from "@modelcontextprotocol/client"; +import { expect, fn, within } from "storybook/test"; +import type { AppElicitationEntry } from "../../../lib/appElicitationController"; +import type { BridgeFactory } from "../AppRenderer/AppRenderer"; +import { AppElicitationHost } from "./AppElicitationHost"; + +const PLACEHOLDER_SANDBOX = "data:text/html,<title>Mock%20Sandbox"; + +const params: ElicitRequest["params"] = { + message: "Choose option A or B.", + requestedSchema: { + type: "object", + properties: { choice: { type: "string", enum: ["option-a", "option-b"] } }, + required: ["choice"], + }, +}; + +function entry( + requestId: string, + resourceUri = "ui://demo/choose-option.html", +): AppElicitationEntry { + return { + requestId, + resourceUri, + params, + signal: new AbortController().signal, + resolve: fn(), + reject: fn(), + }; +} + +/** + * Partial mock: implements only the `AppBridge` members the renderer touches, + * plus the elicitation pair. The double cast bridges the deliberately + * incomplete shape, as in the AppRenderer stories. + */ +function createMockBridge(): AppBridge { + return { + getAppCapabilities: () => ({ elicitation: {} }), + request: async () => ({ + action: "accept", + content: { choice: "option-a" }, + }), + sendHostContextChange: async () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + teardownResource: async () => ({}), + close: async () => {}, + } as unknown as AppBridge; +} + +const okFactory: BridgeFactory = () => createMockBridge(); + +const failingFactory: BridgeFactory = () => + Promise.reject(new Error("Bridge connect failed: handshake timed out")); + +const meta: Meta = { + title: "Elements/AppElicitationHost", + component: AppElicitationHost, + args: { + sandboxPath: PLACEHOLDER_SANDBOX, + bridgeFactory: okFactory, + onSettle: fn(), + onFail: fn(), + }, + parameters: { layout: "fullscreen" }, +}; + +export default meta; +type Story = StoryObj; + +/** One server-attached app, waiting on the user inside the sandbox frame. */ +export const SingleRequest: Story = { + args: { entries: [entry("req-1")] }, + play: async ({ canvasElement }) => { + // Modals portal to document.body, so scope to the whole document. + const body = within(canvasElement.ownerDocument.body); + await expect(await body.findByText("Choose option A or B.")).toBeVisible(); + }, +}; + +/** + * Two elicitations in flight at once, each with its own frame and bridge — the + * request-scoped ownership the contract requires. + */ +export const ConcurrentRequests: Story = { + args: { + entries: [ + entry("req-1", "ui://demo/first.html"), + entry("req-2", "ui://demo/second.html"), + ], + }, +}; + +/** The app could not be brought up; the host falls back to the native form. */ +export const RenderFailure: Story = { + args: { entries: [entry("req-1")], bridgeFactory: failingFactory }, + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await expect(await body.findByText(/App failed to render/)).toBeVisible(); + }, +}; diff --git a/clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx new file mode 100644 index 00000000..3f17a19c --- /dev/null +++ b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.test.tsx @@ -0,0 +1,411 @@ +import { act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import type { AppElicitationEntry } from "../../../lib/appElicitationController"; +import type { BridgeFactory } from "../AppRenderer/AppRenderer"; +import { + APP_ELICITATION_INIT_TIMEOUT_MS, + AppElicitationHost, +} from "./AppElicitationHost"; + +const params: ElicitRequest["params"] = { + message: "Choose an option", + requestedSchema: { + type: "object", + properties: { choice: { type: "string" } }, + required: ["choice"], + }, +}; + +/** + * The renderer's bridge, reduced to what this component drives: the app's + * advertised capabilities and the elicitation round-trip. `emit` lets a test + * play the view's `initialized` signal, which is what triggers the send. + */ +function createMockBridge(options: { + elicitation?: boolean; + answer?: () => Promise; +}) { + const listeners: Record void)[]> = {}; + const answer = + options.answer ?? (() => Promise.resolve({ action: "cancel" })); + const request = vi.fn<(...args: unknown[]) => Promise>(() => + answer(), + ); + return { + bridge: { + getAppCapabilities: () => + options.elicitation === false ? {} : { elicitation: {} }, + request, + teardownResource: vi.fn().mockResolvedValue({}), + close: vi.fn().mockResolvedValue(undefined), + addEventListener: vi.fn( + (event: string, handler: (p: unknown) => void) => { + (listeners[event] ??= []).push(handler); + }, + ), + removeEventListener: vi.fn(), + } as unknown as AppBridge, + request, + emit: (event: string, payload?: unknown) => { + (listeners[event] ?? []).forEach((h) => h(payload)); + }, + }; +} + +function makeEntry( + requestId: string, + resourceUri = "ui://demo/choose-option.html", +): AppElicitationEntry { + return { + requestId, + resourceUri, + params, + signal: new AbortController().signal, + resolve: vi.fn(), + reject: vi.fn(), + }; +} + +/** Two microtasks settle the renderer's bridge promise chain (see AppRenderer). */ +async function flushAsync(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("AppElicitationHost (#1854)", () => { + const onSettle = vi.fn(); + const onFail = vi.fn(); + + beforeEach(() => { + onSettle.mockReset(); + onFail.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders nothing and fails every entry when there is no sandbox", () => { + renderWithMantine( + , + ); + expect(screen.queryByTestId("app-elicitation")).toBeNull(); + expect(onFail).toHaveBeenCalledTimes(2); + expect(onFail.mock.calls[0][1].message).toMatch(/sandbox is not available/); + }); + + it("forwards the request through the app's bridge once it is ready and settles with the result", async () => { + const mock = createMockBridge({ + answer: () => + Promise.resolve({ action: "accept", content: { choice: "option-a" } }), + }); + const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory; + + renderWithMantine( + , + ); + await flushAsync(); + // Nothing is sent before the view says it is ready — an app that has not + // completed ui/initialize has no handler registered yet. + expect(mock.request).not.toHaveBeenCalled(); + + await act(async () => { + mock.emit("initialized"); + await Promise.resolve(); + }); + + expect(mock.request.mock.calls[0][0]).toEqual({ + method: "elicitation/create", + params, + }); + expect(onSettle).toHaveBeenCalledWith("req-1", { + action: "accept", + content: { choice: "option-a" }, + }); + expect(onFail).not.toHaveBeenCalled(); + }); + + it("loads the app from the elicitation's own resource URI", async () => { + const mock = createMockBridge({}); + const factory = vi.fn(() => mock.bridge) as unknown as BridgeFactory; + renderWithMantine( + , + ); + await flushAsync(); + expect(factory).toHaveBeenCalledWith(expect.anything(), { + kind: "resource", + resourceUri: "ui://demo/other.html", + title: params.message, + }); + }); + + it("gives each concurrent request its own frame and bridge", async () => { + const first = createMockBridge({}); + const second = createMockBridge({}); + const bridges = [first.bridge, second.bridge]; + const factory = vi.fn(() => bridges.shift()) as unknown as BridgeFactory; + + renderWithMantine( + , + ); + await flushAsync(); + expect(factory).toHaveBeenCalledTimes(2); + + // Only the second app answers; its result must be attributed to req-2. + await act(async () => { + second.emit("initialized"); + await Promise.resolve(); + }); + expect(first.request).not.toHaveBeenCalled(); + expect(onSettle).toHaveBeenCalledTimes(1); + expect(onSettle.mock.calls[0][0]).toBe("req-2"); + }); + + it("falls back when the app does not advertise elicitation", async () => { + const mock = createMockBridge({ elicitation: false }); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await act(async () => { + mock.emit("initialized"); + await Promise.resolve(); + }); + expect(onSettle).not.toHaveBeenCalled(); + expect(onFail.mock.calls[0][1].message).toMatch( + /does not support elicitation/, + ); + }); + + it("falls back when the bridge request fails", async () => { + const mock = createMockBridge({ + answer: () => Promise.reject(new Error("bridge exploded")), + }); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await act(async () => { + mock.emit("initialized"); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onFail).toHaveBeenCalledWith("req-1", expect.any(Error)); + expect(onFail.mock.calls[0][1].message).toMatch(/bridge exploded/); + }); + + it("wraps a non-Error rejection so the fallback still gets an Error", async () => { + // The rejection crosses a sandbox boundary; an app or bridge can reject + // with anything, and `onFail` must still hand the caller an Error. + const mock = createMockBridge({ + answer: () => Promise.reject("just a string"), + }); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await act(async () => { + mock.emit("initialized"); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(onFail.mock.calls[0][1]).toBeInstanceOf(Error); + expect(onFail.mock.calls[0][1].message).toBe("just a string"); + }); + + it("does not fall back on the init deadline once the request is in flight", async () => { + // The deadline bounds the HANDSHAKE only. A user taking longer than 15s to + // answer must not have their app yanked away. + vi.useFakeTimers(); + let answer: ((result: ElicitResult) => void) | undefined; + const mock = createMockBridge({ + answer: () => new Promise((resolve) => (answer = resolve)), + }); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + mock.emit("initialized"); + await Promise.resolve(); + }); + expect(mock.request).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS * 2); + }); + expect(onFail).not.toHaveBeenCalled(); + + await act(async () => { + answer?.({ action: "accept", content: { choice: "option-a" } }); + await Promise.resolve(); + }); + expect(onSettle).toHaveBeenCalledWith("req-1", { + action: "accept", + content: { choice: "option-a" }, + }); + }); + + it("falls back when the renderer cannot build a bridge at all", async () => { + const factory = vi.fn(() => { + throw new Error("no connected MCP client"); + }) as unknown as BridgeFactory; + renderWithMantine( + , + ); + await flushAsync(); + expect(onFail).toHaveBeenCalled(); + expect(await screen.findByText(/App failed to render/)).toBeTruthy(); + }); + + it("falls back when the app never completes its handshake", async () => { + vi.useFakeTimers(); + const mock = createMockBridge({}); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(APP_ELICITATION_INIT_TIMEOUT_MS + 1); + }); + expect(mock.request).not.toHaveBeenCalled(); + expect(onFail.mock.calls[0][1].message).toMatch(/did not initialize/); + }); + + it("falls back when the user dismisses the modal", async () => { + const user = userEvent.setup(); + const mock = createMockBridge({}); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await user.click( + screen.getByRole("button", { name: /close and use the built-in/i }), + ); + // Dismissing is not an answer: the server still needs one, so this must be + // a fallback rather than a fabricated `cancel`. + expect(onSettle).not.toHaveBeenCalled(); + expect(onFail.mock.calls[0][1].message).toMatch(/dismissed/); + }); + + it("sends only one request even if the view signals ready twice", async () => { + const mock = createMockBridge({}); + renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await act(async () => { + mock.emit("initialized"); + mock.emit("initialized"); + await Promise.resolve(); + }); + expect(mock.request).toHaveBeenCalledTimes(1); + }); + + it("passes decline and cancel through as completed answers", async () => { + const results: ElicitResult[] = [ + { action: "decline" }, + { action: "cancel" }, + ]; + for (const result of results) { + onSettle.mockReset(); + const mock = createMockBridge({ answer: () => Promise.resolve(result) }); + const { unmount } = renderWithMantine( + mock.bridge) as unknown as BridgeFactory} + onSettle={onSettle} + onFail={onFail} + />, + ); + await flushAsync(); + await act(async () => { + mock.emit("initialized"); + await Promise.resolve(); + }); + expect(onSettle).toHaveBeenCalledWith("req-1", result); + unmount(); + } + }); +}); diff --git a/clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx new file mode 100644 index 00000000..c66f932b --- /dev/null +++ b/clients/web/src/components/elements/AppElicitation/AppElicitationHost.tsx @@ -0,0 +1,270 @@ +import { Alert, CloseButton, Group, Modal, Stack, Text } from "@mantine/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ElicitResult } from "@modelcontextprotocol/client"; +import type { AppElicitationEntry } from "../../../lib/appElicitationController"; +import { + AppRenderer, + type AppRenderSource, + type AppRendererHandle, + type AppRendererStatus, + type BridgeFactory, +} from "../AppRenderer/AppRenderer"; + +/** + * How long an app has to complete `ui/initialize` before the host gives up and + * lets the native elicitation UI take the request. + * + * Short on purpose, and unrelated to {@link APP_ELICITATION_TIMEOUT_MS} (which + * bounds the *answer*): nothing here waits on a human, so a sandbox that has + * not handshaken in this window is broken rather than slow, and the user is + * better served by the native form than by a spinner. + */ +export const APP_ELICITATION_INIT_TIMEOUT_MS = 15_000; + +/** + * Modal shell for one app-rendered elicitation. + * + * Deliberately headerless (`withCloseButton: false`, no `title`): concurrent + * elicitations mean two of these are open at once, and Mantine's modal header + * is a `
` — a second banner landmark, which axe flags as both + * `landmark-no-duplicate-banner` and `landmark-unique`. The title and the close + * affordance live in the body instead, and each dialog is named by its own + * `aria-label` so the two remain distinguishable. + */ +const ElicitationModal = Modal.Root.withProps({ + centered: true, + size: "lg", + closeOnClickOutside: false, +}); + +const ElicitationOverlay = Modal.Overlay.withProps({ + backgroundOpacity: 0.55, + blur: 2, +}); + +/** Title row: the server's prompt plus the dismiss control. */ +const TitleRow = Group.withProps({ + justify: "space-between", + align: "flex-start", + wrap: "nowrap", + gap: "sm", +}); + +const TitleText = Text.withProps({ + fw: 600, + size: "md", +}); + +/** Fixed-height frame the app renders into; apps size themselves within it. */ +const FrameBox = Stack.withProps({ + h: 360, + gap: 0, +}); + +const PromptText = Text.withProps({ + size: "sm", + c: "dimmed", +}); + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +export interface AppElicitationHostProps { + /** Elicitations currently awaiting an app answer, oldest first. */ + entries: AppElicitationEntry[]; + /** + * The inspector's sandbox-proxy URL. Absent means the sandbox controller is + * not running, so nothing can be rendered — every entry falls back at once. + */ + sandboxPath?: string; + /** Builds the per-app bridge. Must advertise `hostCapabilities.elicitation`. */ + bridgeFactory: BridgeFactory; + /** The app answered: hand the standard result back to the server. */ + onSettle: (requestId: string, result: ElicitResult) => void; + /** The app could not answer: fall back to the native elicitation UI. */ + onFail: (requestId: string, error: Error) => void; +} + +/** + * Renders every pending app-rendered elicitation (#1854), one modal and one + * bridge per request. + * + * Keyed by `requestId` rather than by resource URI so that two concurrent + * requests — even for the SAME app — get distinct React subtrees, distinct + * iframes and distinct bridges. That is what makes the ownership request-scoped + * in practice, not just on paper. + */ +export function AppElicitationHost({ + entries, + sandboxPath, + bridgeFactory, + onSettle, + onFail, +}: AppElicitationHostProps) { + // No sandbox → nothing can render. Fail every entry immediately rather than + // showing an empty modal the user cannot act on. + useEffect(() => { + if (sandboxPath) return; + for (const entry of entries) { + onFail( + entry.requestId, + new Error("MCP App sandbox is not available in this session"), + ); + } + }, [entries, sandboxPath, onFail]); + + if (!sandboxPath) return null; + + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} + +interface AppElicitationFrameProps { + entry: AppElicitationEntry; + sandboxPath: string; + bridgeFactory: BridgeFactory; + onSettle: (requestId: string, result: ElicitResult) => void; + onFail: (requestId: string, error: Error) => void; +} + +/** + * One request: mount the app, and the moment its view reports `ready`, forward + * the original `elicitation/create` through THAT app's bridge. + * + * Every failure route ends in `onFail`, which is the fallback signal — an + * unreachable sandbox, a view that never handshakes, an app with no elicitation + * capability, a bridge error, or the user dismissing the modal. + */ +function AppElicitationFrame({ + entry, + sandboxPath, + bridgeFactory, + onSettle, + onFail, +}: AppElicitationFrameProps) { + const rendererRef = useRef(null); + // Guards the one-shot send: `ready` can fire again after a bridge rebuild, + // and a second `elicitation/create` for the same server request would be a + // duplicate the server never asked for. + const sentRef = useRef(false); + const [status, setStatus] = useState("loading"); + + const source = useMemo( + () => ({ + kind: "resource", + resourceUri: entry.resourceUri, + title: entry.params.message, + }), + [entry.resourceUri, entry.params.message], + ); + + const fail = useCallback( + (error: Error) => { + onFail(entry.requestId, error); + }, + [entry.requestId, onFail], + ); + + // Dismissing is not an answer: the server is still waiting, so hand the + // request to the native elicitation UI rather than inventing a `cancel`. + const dismiss = useCallback( + () => fail(new Error("App-rendered elicitation dismissed")), + [fail], + ); + + // Initialization deadline. Cleared as soon as the request goes out, so it + // only ever bounds the handshake and never the user's answer. + useEffect(() => { + const timer = window.setTimeout(() => { + if (sentRef.current) return; + fail(new Error("MCP App did not initialize in time")); + }, APP_ELICITATION_INIT_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [fail]); + + const handleStatus = useCallback( + (next: AppRendererStatus) => { + setStatus(next); + if (next === "error") { + fail(new Error("MCP App failed to render")); + return; + } + if (next !== "ready" || sentRef.current) return; + sentRef.current = true; + const handle = rendererRef.current; + /* v8 ignore next 4 -- defensive: `ready` is dispatched from the renderer's + own bridge callback, so its imperative handle is always attached by the + time this runs. */ + if (!handle) { + fail(new Error("MCP App renderer is unavailable")); + return; + } + handle + .requestElicitation(entry.params) + .then((result) => onSettle(entry.requestId, result)) + .catch((err: unknown) => fail(toError(err))); + }, + [entry.params, entry.requestId, fail, onSettle], + ); + + return ( + + + {/* `aria-label` and the `data-*` attributes go on the CONTENT (the + role="dialog" element), not the Root — the Root is only a portal + wrapper, so a name placed there never reaches the dialog. Named per + request rather than per prompt: two concurrent elicitations can carry + the same message, and two identically-named dialogs are what + `landmark-unique` rejects. */} + + + + + {entry.params.message} + + + + Answering through the server-provided MCP App. + + {status === "error" ? ( + + Falling back to the built-in elicitation form. + + ) : ( + + + + )} + + + + + ); +} diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx index b1df1c0b..c24fa5a0 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.stories.tsx @@ -61,7 +61,7 @@ const meta: Meta = { component: AppRenderer, args: { sandboxPath: PLACEHOLDER_SANDBOX, - tool: cohortTool, + source: { kind: "tool", tool: cohortTool }, onError: fn(), }, parameters: { diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx index c309e43a..a9a167cc 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.test.tsx @@ -85,7 +85,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -99,7 +99,10 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -112,14 +115,14 @@ describe("AppRenderer", () => { renderWithMantine( , ); await flushAsync(); expect(factory).toHaveBeenCalledTimes(1); expect(factory.mock.calls[0]?.[0]).toBeInstanceOf(HTMLIFrameElement); - expect(factory.mock.calls[0]?.[1]).toBe(tool); + expect(factory.mock.calls[0]?.[1]).toEqual({ kind: "tool", tool }); }); it("forwards sendToolInput through the bridge once initialized", async () => { @@ -129,7 +132,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -153,7 +156,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -173,7 +176,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -205,7 +208,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -228,7 +231,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -247,7 +250,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} onSizeChange={onSizeChange} />, @@ -264,7 +267,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -283,7 +286,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} displayMode="inline" onRequestDisplayMode={onRequestDisplayMode} @@ -301,7 +304,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} displayMode="fullscreen" />, @@ -317,7 +320,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -334,7 +337,7 @@ describe("AppRenderer", () => { asBridge(bridge)} partialInputs={[{ city: "N" }, { city: "New" }]} />, @@ -363,7 +366,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -381,7 +384,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} onLog={onLog} />, @@ -398,7 +401,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -415,7 +418,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} onMessage={onMessage} />, @@ -434,7 +437,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -452,7 +455,7 @@ describe("AppRenderer", () => { const { rerender } = renderWithMantine( , @@ -463,7 +466,7 @@ describe("AppRenderer", () => { rerender( , @@ -480,7 +483,7 @@ describe("AppRenderer", () => { const { rerender } = renderWithMantine( , @@ -491,7 +494,7 @@ describe("AppRenderer", () => { rerender( , @@ -523,7 +526,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -564,7 +567,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -588,7 +591,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( asBridge(bridge)} />, ); @@ -665,7 +668,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} containerRef={{ current: container }} />, @@ -684,7 +687,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} containerRef={{ current: container }} />, @@ -703,7 +706,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} containerRef={{ current: container }} />, @@ -721,7 +724,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} containerRef={{ current: container }} />, @@ -752,7 +755,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} containerRef={{ current: container }} />, @@ -766,7 +769,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} />, ); @@ -779,7 +782,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( asBridge(bridge)} />, ); @@ -807,7 +810,7 @@ describe("AppRenderer", () => { , @@ -837,7 +840,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -864,7 +867,7 @@ describe("AppRenderer", () => { asBridge(bridge)} />, ); @@ -901,7 +904,7 @@ describe("AppRenderer", () => { , ); @@ -925,7 +928,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( asBridge(bridge)} />, ); @@ -949,7 +952,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( , ); @@ -972,7 +975,7 @@ describe("AppRenderer", () => { renderWithMantine( asBridge(bridge)} onAppStatusChange={onAppStatusChange} />, @@ -993,7 +996,7 @@ describe("AppRenderer", () => { renderWithMantine( , @@ -1010,7 +1013,7 @@ describe("AppRenderer", () => { renderWithMantine( , @@ -1027,7 +1030,7 @@ describe("AppRenderer", () => { renderWithMantine( , @@ -1044,7 +1047,7 @@ describe("AppRenderer", () => { renderWithMantine( , @@ -1059,7 +1062,7 @@ describe("AppRenderer", () => { renderWithMantine( , ); @@ -1078,7 +1081,7 @@ describe("AppRenderer", () => { const { rerender } = renderWithMantine( , ); @@ -1087,7 +1090,7 @@ describe("AppRenderer", () => { rerender( , ); @@ -1117,7 +1120,7 @@ describe("AppRenderer", () => { const { rerender } = renderWithMantine( , ); @@ -1126,14 +1129,17 @@ describe("AppRenderer", () => { rerender( , ); await flushAsync(); expect(factory).toHaveBeenCalledTimes(2); - expect(factory.mock.calls[1]?.[1]).toBe(otherTool); + expect(factory.mock.calls[1]?.[1]).toEqual({ + kind: "tool", + tool: otherTool, + }); expect(first.teardownResource).toHaveBeenCalledTimes(1); expect(first.close).toHaveBeenCalledTimes(1); }); @@ -1144,7 +1150,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( asBridge(bridge)} />, ); @@ -1164,7 +1170,7 @@ describe("AppRenderer", () => { const { unmount } = renderWithMantine( asBridge(bridge)} />, ); @@ -1177,4 +1183,114 @@ describe("AppRenderer", () => { expect(bridge.teardownResource).toHaveBeenCalledTimes(1); expect(bridge.close).toHaveBeenCalledTimes(1); }); + + describe("app-rendered elicitation (#1854)", () => { + /** A bridge that can answer an `elicitation/create` sent through it. */ + function elicitationBridge( + result: unknown = { action: "accept", content: { choice: "a" } }, + ) { + const bridge = createMockBridge(); + const withElicitation = Object.assign(bridge, { + getAppCapabilities: () => ({ elicitation: {} }), + request: vi.fn().mockResolvedValue(result), + }); + return withElicitation; + } + + it("renders an app named by resource URI, using the URI as the frame title", () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + expect(screen.getByTitle("ui://demo/pick.html")).toBeTruthy(); + }); + + it("prefers an explicit title over the URI", () => { + const bridge = createMockBridge(); + renderWithMantine( + asBridge(bridge)} + />, + ); + expect(screen.getByTitle("Choose an option")).toBeTruthy(); + }); + + it("sends the request through the live bridge once the view is initialized", async () => { + const bridge = elicitationBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + act(() => bridge.emit("initialized")); + await expect( + ref.current!.requestElicitation({ + message: "Choose", + requestedSchema: { type: "object", properties: {} }, + }), + ).resolves.toEqual({ action: "accept", content: { choice: "a" } }); + }); + + it("rejects rather than buffering when the view is not ready", async () => { + // Unlike tool input/result there is a server waiting on this, so a + // caller that arrives early must learn now and fall back. + const bridge = elicitationBridge(); + const ref = createRef(); + renderWithMantine( + asBridge(bridge)} + />, + ); + await flushAsync(); + await expect( + ref.current!.requestElicitation({ + message: "Choose", + requestedSchema: { type: "object", properties: {} }, + }), + ).rejects.toThrow(/not ready/); + expect(bridge.request).not.toHaveBeenCalled(); + }); + + it("keeps a live bridge when the source object is recreated with the same tool", async () => { + // A caller writing the source inline produces a fresh object every + // render; rebuilding on that double-loads the sandbox. + const bridge = createMockBridge(); + const factory = vi.fn(() => asBridge(bridge)); + const { rerender } = renderWithMantine( + , + ); + await flushAsync(); + rerender( + , + ); + await flushAsync(); + expect(factory).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx index 19eb8583..b14cd1d6 100644 --- a/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx +++ b/clients/web/src/components/elements/AppRenderer/AppRenderer.tsx @@ -15,9 +15,18 @@ import type { } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { CallToolResult, + ElicitRequest, + ElicitResult, LoggingMessageNotification, - Tool, } from "@modelcontextprotocol/client"; +import { requestAppElicitation } from "./requestAppElicitation"; +import { + appSourceTitle, + sameAppSource, + type AppRenderSource, +} from "./appRenderSource"; + +export type { AppRenderSource } from "./appRenderSource"; import { currentStyles, currentTheme, @@ -32,13 +41,23 @@ import { */ export type BridgeFactory = ( iframe: HTMLIFrameElement, - tool: Tool, + source: AppRenderSource, ) => AppBridge | Promise; export interface AppRendererHandle { sendToolInput(args: Record): Promise; sendToolResult(result: CallToolResult): Promise; sendToolCancelled(reason: string): Promise; + /** + * Forward a form-mode `elicitation/create` through THIS renderer's bridge and + * resolve with the app's standard `ElicitResult` (#1854). + * + * Rejects — rather than resolving with anything invented — when the app is not + * live, did not advertise `elicitation`, or fails the request, because the + * caller's contract is that a rejection means "fall back to the native UI" + * while a resolution is a real user decision. + */ + requestElicitation(params: ElicitRequest["params"]): Promise; teardown(): Promise; } @@ -54,7 +73,7 @@ export type AppRendererStatus = "loading" | "ready" | "error"; export interface AppRendererProps { sandboxPath: string; - tool: Tool; + source: AppRenderSource; bridgeFactory: BridgeFactory; onError?: (err: Error) => void; /** @@ -135,7 +154,7 @@ async function disposeBridge(bridge: AppBridge): Promise { /** * Bridge lifecycle (the interlocking refs below): * - * mount ─▶ build (buildId++) ─▶ factory(iframe,tool) ─async─▶ bridgeRef set + * mount ─▶ build (buildId++) ─▶ factory(iframe,source) ─async─▶ bridgeRef set * │ on "initialized" * ▼ → flushPending * cleanup ─▶ scheduleDispose() ──microtask──▶ dispose (unless cancelled) @@ -154,7 +173,7 @@ async function disposeBridge(bridge: AppBridge): Promise { */ export function AppRenderer({ sandboxPath, - tool, + source, bridgeFactory, onError, onAppStatusChange, @@ -182,7 +201,7 @@ export function AppRenderer({ const lastDepsRef = useRef<{ bridgeFactory: BridgeFactory; sandboxPath: string; - tool: Tool; + source: AppRenderSource; } | null>(null); const onErrorRef = useRef(onError); const onAppStatusChangeRef = useRef(onAppStatusChange); @@ -268,7 +287,7 @@ export function AppRenderer({ prev !== null && prev.bridgeFactory === bridgeFactory && prev.sandboxPath === sandboxPath && - prev.tool === tool; + sameAppSource(prev.source, source); // A disposal scheduled by the immediately-preceding cleanup means we are in // a synchronous re-setup. If the inputs are identical (StrictMode's @@ -298,7 +317,7 @@ export function AppRenderer({ if (old) void disposeBridge(old); } - lastDepsRef.current = { bridgeFactory, sandboxPath, tool }; + lastDepsRef.current = { bridgeFactory, sandboxPath, source }; const buildId = ++buildIdRef.current; teardownStartedRef.current = false; initializedRef.current = false; @@ -311,7 +330,7 @@ export function AppRenderer({ let pending: Promise; try { - pending = Promise.resolve(bridgeFactory(iframe, tool)); + pending = Promise.resolve(bridgeFactory(iframe, source)); } catch (err) { onAppStatusChangeRef.current?.("error"); onErrorRef.current?.(toError(err)); @@ -391,7 +410,7 @@ export function AppRenderer({ }, [ bridgeFactory, sandboxPath, - tool, + source, containerRef, flushPending, scheduleDispose, @@ -489,6 +508,18 @@ export function AppRenderer({ pendingResultRef.current = result; flushPending(); }, + async requestElicitation(params) { + const bridge = bridgeRef.current; + // Not "not ready yet, buffer it" like tool input/result: an elicitation + // has a server waiting on it, so a caller that arrives before the + // handshake must learn that now and fall back, not block. + if (!bridge || !initializedRef.current) { + throw new Error( + "MCP App is not ready to receive an elicitation request", + ); + } + return requestAppElicitation(bridge, params); + }, async sendToolCancelled(reason) { const bridge = bridgeRef.current; if (!bridge) return; @@ -528,7 +559,7 @@ export function AppRenderer({ component="iframe" ref={iframeRef} src={sandboxPath} - title={tool.title ?? tool.name} + title={appSourceTitle(source)} w="100%" h="100%" bd={0} diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts new file mode 100644 index 00000000..6e15829e --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client"; +import { + appAdvertisesElicitation, + observeAppCapabilities, +} from "./appCapabilities"; + +function makeBridge(parsed?: Record): AppBridge { + return { getAppCapabilities: () => parsed } as unknown as AppBridge; +} + +function makeTransport(): { + transport: Transport; + inner: ReturnType; +} { + const inner = vi.fn(); + const transport = { onmessage: inner } as unknown as Transport; + return { transport, inner }; +} + +function initializeFrame(appCapabilities: unknown): JSONRPCMessage { + return { + jsonrpc: "2.0", + id: 1, + method: "ui/initialize", + params: { appCapabilities }, + } as unknown as JSONRPCMessage; +} + +describe("appCapabilities (#1854)", () => { + it("records the raw ui/initialize capabilities the bridge's schema strips", () => { + // The whole reason this module exists: ext-apps 1.7.5 parses away the + // `elicitation` key, so an app that DID advertise it reads as one that did + // not — and every negotiated elicitation silently becomes a fallback. + const bridge = makeBridge({}); + const { transport, inner } = makeTransport(); + observeAppCapabilities(bridge, transport); + + expect(appAdvertisesElicitation(bridge)).toBe(false); + transport.onmessage?.(initializeFrame({ elicitation: {} })); + expect(appAdvertisesElicitation(bridge)).toBe(true); + // The bridge's own handler still runs — observing must not swallow. + expect(inner).toHaveBeenCalledTimes(1); + }); + + it("prefers the bridge's own value once ext-apps carries it", () => { + const bridge = makeBridge({ elicitation: {} }); + expect(appAdvertisesElicitation(bridge)).toBe(true); + }); + + it("is false for an app that advertised no elicitation", () => { + const bridge = makeBridge({ availableDisplayModes: ["inline"] }); + const { transport } = makeTransport(); + observeAppCapabilities(bridge, transport); + transport.onmessage?.(initializeFrame({ availableDisplayModes: [] })); + expect(appAdvertisesElicitation(bridge)).toBe(false); + }); + + it("ignores other methods and non-object capabilities", () => { + const bridge = makeBridge(undefined); + const { transport } = makeTransport(); + observeAppCapabilities(bridge, transport); + transport.onmessage?.({ + jsonrpc: "2.0", + method: "ui/notifications/initialized", + } as JSONRPCMessage); + transport.onmessage?.(initializeFrame("not-an-object")); + transport.onmessage?.(initializeFrame(null)); + expect(appAdvertisesElicitation(bridge)).toBe(false); + }); + + it("keeps bridges independent", () => { + const a = makeBridge({}); + const b = makeBridge({}); + const first = makeTransport(); + const second = makeTransport(); + observeAppCapabilities(a, first.transport); + observeAppCapabilities(b, second.transport); + first.transport.onmessage?.(initializeFrame({ elicitation: {} })); + expect(appAdvertisesElicitation(a)).toBe(true); + expect(appAdvertisesElicitation(b)).toBe(false); + }); + + it("tolerates a transport with no prior handler", () => { + const bridge = makeBridge({}); + const transport = {} as unknown as Transport; + observeAppCapabilities(bridge, transport); + expect(() => + transport.onmessage?.(initializeFrame({ elicitation: {} })), + ).not.toThrow(); + expect(appAdvertisesElicitation(bridge)).toBe(true); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/appCapabilities.ts b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts new file mode 100644 index 00000000..4ff832c1 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/appCapabilities.ts @@ -0,0 +1,67 @@ +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { Transport } from "@modelcontextprotocol/client"; + +/** + * The app capabilities exactly as the view sent them, per bridge. + * + * `AppBridge.getAppCapabilities()` cannot be used for `elicitation` (#1854): + * ext-apps 1.7.5 parses the view's `ui/initialize` params through + * `McpUiAppCapabilitiesSchema`, a plain Zod object, so any key that schema does + * not declare is **stripped before the bridge stores it**. `elicitation` is + * exactly such a key — it is what ext-apps#733 adds — so an app that correctly + * advertises it looks, through the bridge's own accessor, like an app that did + * not. That silently turns every negotiated elicitation into a native-UI + * fallback, which is indistinguishable from "the feature is off". + * + * So the raw frame is observed on the way in and recorded here. A WeakMap keeps + * this per-bridge (never global) and lets the entry die with the bridge. + * + * Delete this module when ext-apps ships #733: `getAppCapabilities()` will + * carry `elicitation` itself, and {@link appAdvertisesElicitation} already + * prefers that value. + */ +const rawAppCapabilities = new WeakMap>(); + +/** Shape of the `ui/initialize` frame this reads — nothing else is touched. */ +interface UiInitializeFrame { + method?: unknown; + params?: { appCapabilities?: unknown }; +} + +/** + * Wrap a connected bridge's transport so the view's `ui/initialize` params are + * recorded before the bridge parses them. + * + * Call AFTER `bridge.connect(transport)` — `connect` installs the handler this + * wraps. That ordering is safe: the view cannot send `ui/initialize` until the + * host has pushed its HTML into the sandbox, which happens later still. + */ +export function observeAppCapabilities( + bridge: AppBridge, + transport: Transport, +): void { + const inner = transport.onmessage?.bind(transport); + transport.onmessage = (message, extra) => { + const frame = message as UiInitializeFrame; + if (frame.method === "ui/initialize") { + const advertised = frame.params?.appCapabilities; + if (typeof advertised === "object" && advertised !== null) { + rawAppCapabilities.set(bridge, advertised as Record); + } + } + inner?.(message, extra); + }; +} + +/** + * Whether the app running on this bridge advertised the `elicitation` + * capability. Prefers the bridge's own accessor (correct once ext-apps#733 + * ships) and falls back to the observed raw frame. + */ +export function appAdvertisesElicitation(bridge: AppBridge): boolean { + const parsed = bridge.getAppCapabilities() as + | Record + | undefined; + if (parsed?.elicitation) return true; + return Boolean(rawAppCapabilities.get(bridge)?.elicitation); +} diff --git a/clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts b/clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts new file mode 100644 index 00000000..cfb61f09 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/appRenderSource.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import type { Tool } from "@modelcontextprotocol/client"; +import { + appSourceTitle, + sameAppSource, + type AppRenderSource, +} from "./appRenderSource"; + +const tool: Tool = { + name: "cohort_app", + title: "Cohort App", + inputSchema: { type: "object" }, +}; +const other: Tool = { name: "other_app", inputSchema: { type: "object" } }; + +const toolSource: AppRenderSource = { kind: "tool", tool }; +const resourceSource: AppRenderSource = { + kind: "resource", + resourceUri: "ui://demo/pick.html", +}; + +describe("appRenderSource (#1854)", () => { + describe("sameAppSource", () => { + it("is true for the identical object", () => { + expect(sameAppSource(toolSource, toolSource)).toBe(true); + }); + + it("is true for a re-created wrapper around the same tool", () => { + // The reason the comparison is not `===`: a caller writing the source + // inline makes a new object each render, and rebuilding on that + // double-loads the sandbox. + expect(sameAppSource(toolSource, { kind: "tool", tool })).toBe(true); + }); + + it("is false when the Tool identity changes", () => { + // Preserved from before the union existed: a re-listed tool rebuilds. + expect(sameAppSource(toolSource, { kind: "tool", tool: other })).toBe( + false, + ); + expect( + sameAppSource(toolSource, { kind: "tool", tool: { ...tool } }), + ).toBe(false); + }); + + it("compares resource sources by URI and title", () => { + expect( + sameAppSource(resourceSource, { + kind: "resource", + resourceUri: "ui://demo/pick.html", + }), + ).toBe(true); + expect( + sameAppSource(resourceSource, { + kind: "resource", + resourceUri: "ui://demo/other.html", + }), + ).toBe(false); + expect( + sameAppSource(resourceSource, { + kind: "resource", + resourceUri: "ui://demo/pick.html", + title: "Pick one", + }), + ).toBe(false); + }); + + it("is false across kinds", () => { + expect(sameAppSource(toolSource, resourceSource)).toBe(false); + expect(sameAppSource(resourceSource, toolSource)).toBe(false); + }); + }); + + describe("appSourceTitle", () => { + it("prefers a tool's title, falling back to its name", () => { + expect(appSourceTitle(toolSource)).toBe("Cohort App"); + expect(appSourceTitle({ kind: "tool", tool: other })).toBe("other_app"); + }); + + it("prefers an explicit resource title, falling back to the URI", () => { + expect(appSourceTitle(resourceSource)).toBe("ui://demo/pick.html"); + expect( + appSourceTitle({ ...resourceSource, title: "Choose an option" }), + ).toBe("Choose an option"); + }); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/appRenderSource.ts b/clients/web/src/components/elements/AppRenderer/appRenderSource.ts new file mode 100644 index 00000000..33143115 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/appRenderSource.ts @@ -0,0 +1,49 @@ +import type { Tool } from "@modelcontextprotocol/client"; + +/** + * What the renderer loads into the sandbox. + * + * An App tool names its UI resource through `_meta.ui.resourceUri`, but an + * app-rendered elicitation (#1854) has no tool at all — the server names the + * resource on the `elicitation/create` request itself. The source is therefore + * a union rather than a `Tool`, so the same renderer, bridge factory and + * sandbox lifecycle serve both without either faking the other's shape. + * + * Lives beside `AppRenderer` rather than in it because a module that exports a + * component may export nothing else (the react-refresh rule). + */ +export type AppRenderSource = + | { readonly kind: "tool"; readonly tool: Tool } + | { + readonly kind: "resource"; + /** Absolute `ui://` URI of the app to load. */ + readonly resourceUri: string; + /** Frame title; falls back to the URI. */ + readonly title?: string; + }; + +/** + * Whether two sources name the same app, so the renderer can keep a live bridge + * instead of rebuilding it. + * + * Identity alone is not enough: a caller that writes the source inline produces + * a fresh object every render, and rebuilding on that double-loads the sandbox + * and races the app's handshake (the failure AppRenderer's reuse dance exists to + * avoid). For a tool the comparison stays *identity of the Tool*, exactly as + * before this union existed, so a re-listed tool still rebuilds. + */ +export function sameAppSource(a: AppRenderSource, b: AppRenderSource): boolean { + if (a === b) return true; + if (a.kind === "tool" && b.kind === "tool") return a.tool === b.tool; + if (a.kind === "resource" && b.kind === "resource") { + return a.resourceUri === b.resourceUri && a.title === b.title; + } + return false; +} + +/** The iframe's accessible name for a source. */ +export function appSourceTitle(source: AppRenderSource): string { + return source.kind === "tool" + ? (source.tool.title ?? source.tool.name) + : (source.title ?? source.resourceUri); +} diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts index 13fa9f55..cccdd33d 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -113,7 +113,7 @@ describe("createAppBridgeFactory", () => { getClient: () => null, readResource: vi.fn(), }); - await expect(factory(makeIframe(), tool)).rejects.toThrow( + await expect(factory(makeIframe(), { kind: "tool", tool })).rejects.toThrow( /no connected MCP client/, ); }); @@ -123,7 +123,9 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource: vi.fn(), }); - await expect(factory(makeIframe(false), tool)).rejects.toThrow(/no window/); + await expect( + factory(makeIframe(false), { kind: "tool", tool }), + ).rejects.toThrow(/no window/); }); it("constructs the bridge with the client, host info, capabilities and theme, then connects", async () => { @@ -134,7 +136,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource: vi.fn().mockResolvedValue(uiResource("

hi

")), }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); expect(bridgeInstances).toHaveLength(1); const bridge = bridgeInstances[0]; expect(bridge.ctorArgs[0]).toBe(fakeClient); @@ -167,7 +169,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); @@ -215,7 +217,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); bridgeInstances[0].emit("sandboxready"); await flush(); expect(HOST_CAPABILITIES.sandbox).toBeUndefined(); @@ -235,7 +237,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); @@ -256,8 +258,8 @@ describe("createAppBridgeFactory", () => { readResource, }); await factory(makeIframe(), { - name: "plain", - inputSchema: { type: "object" }, + kind: "tool", + tool: { name: "plain", inputSchema: { type: "object" } }, }); bridgeInstances[0].emit("sandboxready"); await flush(); @@ -274,7 +276,7 @@ describe("createAppBridgeFactory", () => { readResource, onResourceError, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); @@ -295,7 +297,7 @@ describe("createAppBridgeFactory", () => { readResource, onResourceError, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); @@ -317,7 +319,7 @@ describe("createAppBridgeFactory", () => { readResource, onResourceError, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); @@ -335,7 +337,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource, }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; bridge.emit("sandboxready"); await flush(); @@ -352,7 +354,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource: vi.fn().mockResolvedValue(uiResource("

x

")), }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; await expect( @@ -376,7 +378,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource: vi.fn().mockResolvedValue(uiResource("

x

")), }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); expect(bridgeInstances[0].ctorArgs[2]).toMatchObject({ downloadFile: {} }); }); @@ -394,7 +396,7 @@ describe("createAppBridgeFactory", () => { getClient: () => fakeClient, readResource: vi.fn().mockResolvedValue(uiResource("

x

")), }); - await factory(makeIframe(), tool); + await factory(makeIframe(), { kind: "tool", tool }); return bridgeInstances[0]; } diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 9294c514..3aea5c1c 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -8,7 +8,7 @@ import type { McpUiHostCapabilities, McpUiResourceMeta, } from "@modelcontextprotocol/ext-apps/app-bridge"; -import type { Client } from "@modelcontextprotocol/client"; +import type { Client, Transport } from "@modelcontextprotocol/client"; import type { EmbeddedResource, Implementation, @@ -26,7 +26,21 @@ import { isHttpUrl, } from "../../../lib/downloadFile"; import { snapshotHostContext } from "./hostContext"; -import type { BridgeFactory } from "./AppRenderer"; +import { observeAppCapabilities } from "./appCapabilities"; +import type { AppRenderSource, BridgeFactory } from "./AppRenderer"; + +/** + * The `ui://` resource a render source loads, or `undefined` for an App tool + * whose `_meta` names none (in which case there is nothing to push into the + * sandbox and the frame stays empty). + */ +function resolveSourceUri(source: AppRenderSource): string | undefined { + return source.kind === "resource" + ? source.resourceUri + : getToolUiResourceUri( + source.tool as Parameters[0], + ); +} /** * Host identity advertised to MCP Apps during the bridge handshake. Static — @@ -74,6 +88,16 @@ export interface AppBridgeFactoryDeps { * frame; the error is also always console.error'd. */ onResourceError?: (err: Error) => void; + /** + * Advertise `hostCapabilities.elicitation` — "this host can forward a + * form-mode `elicitation/create` to the app and return its result to the + * server" (#1854). + * + * Off by default, and deliberately per-factory rather than global: an App + * *tool* frame is never handed an elicitation, so claiming the capability + * there would tell the app something untrue about what its host will do. + */ + advertiseElicitation?: boolean; } /** First text content block of a UI resource, plus its `_meta` (sandbox hints). */ @@ -198,7 +222,7 @@ function downloadResourceItem(item: EmbeddedResource | ResourceLink): boolean { export function createAppBridgeFactory( deps: AppBridgeFactoryDeps, ): BridgeFactory { - return async (iframe, tool) => { + return async (iframe, source) => { const client = deps.getClient(); if (!client) { throw new Error("Cannot render MCP App: no connected MCP client."); @@ -211,7 +235,17 @@ export function createAppBridgeFactory( // Per-app copy so the approved-sandbox echo (set on sandboxready below) // never mutates the shared HOST_CAPABILITIES constant — each app may // declare its own csp/permissions. - const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES }; + const hostCapabilities: McpUiHostCapabilities = { + ...HOST_CAPABILITIES, + // `elicitation` is not part of ext-apps 1.7.5's `McpUiHostCapabilities` + // (ext-apps#733 adds it), so it is spread in as an extra key. The bridge + // forwards the capabilities object verbatim in its `ui/initialize` + // response, which is exactly what the app reads. TODO: drop the cast when + // a release containing #733 ships. + ...(deps.advertiseElicitation + ? ({ elicitation: {} } as Partial) + : {}), + }; // ext-apps' `AppBridge` peers on SDK v1's `Client`/`Implementation`; both // are runtime-compatible with v2's. Cast at this single construction // boundary. TODO: drop when ext-apps#702 ships a v2 peer release. @@ -234,9 +268,7 @@ export function createAppBridgeFactory( bridge.addEventListener("sandboxready", () => { void (async () => { try { - const uri = getToolUiResourceUri( - tool as Parameters[0], - ); + const uri = resolveSourceUri(source); if (!uri) return; const result = await deps.readResource(uri); const { html, meta } = extractHtmlAndMeta(result); @@ -338,6 +370,10 @@ export function createAppBridgeFactory( const transport = new PostMessageTransport(targetWindow, targetWindow); await bridge.connect(transport); + // Record the view's raw `ui/initialize` capabilities before the bridge's + // own schema strips the keys it predates (#1854). Must follow `connect`, + // which is what installs the handler this wraps. + observeAppCapabilities(bridge, transport as unknown as Transport); return bridge; }; } diff --git a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts new file mode 100644 index 00000000..339f491b --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ElicitRequest } from "@modelcontextprotocol/client"; +import { + APP_ELICITATION_TIMEOUT_MS, + requestAppElicitation, +} from "./requestAppElicitation"; + +const params: ElicitRequest["params"] = { + message: "Choose an option", + requestedSchema: { + type: "object", + properties: { choice: { type: "string" } }, + required: ["choice"], + }, +}; + +function makeBridge(options: { + appCapabilities?: Record; + request?: ReturnType; +}) { + return { + getAppCapabilities: () => options.appCapabilities, + request: options.request ?? vi.fn(), + } as unknown as AppBridge; +} + +describe("requestAppElicitation (#1854)", () => { + it("sends the standard method and params through the given bridge", async () => { + const request = vi + .fn() + .mockResolvedValue({ action: "accept", content: { choice: "option-a" } }); + const bridge = makeBridge({ + appCapabilities: { elicitation: {} }, + request, + }); + + await expect(requestAppElicitation(bridge, params)).resolves.toEqual({ + action: "accept", + content: { choice: "option-a" }, + }); + // The method and params must reach the app UNCHANGED — the whole contract + // is that no custom method or result shape is introduced. + const [sent, , options] = request.mock.calls[0]; + expect(sent).toEqual({ method: "elicitation/create", params }); + expect(options).toEqual({ timeout: APP_ELICITATION_TIMEOUT_MS }); + }); + + it("honors a caller-supplied timeout", async () => { + const request = vi.fn().mockResolvedValue({ action: "cancel" }); + const bridge = makeBridge({ + appCapabilities: { elicitation: {} }, + request, + }); + await requestAppElicitation(bridge, params, 1234); + expect(request.mock.calls[0][2]).toEqual({ timeout: 1234 }); + }); + + it("fails closed when the app did not advertise elicitation", async () => { + const request = vi.fn(); + const bridge = makeBridge({ appCapabilities: {}, request }); + await expect(requestAppElicitation(bridge, params)).rejects.toThrow( + /does not support elicitation/, + ); + // Not merely "returns an error" — nothing is sent at all, so a wedged app + // cannot hold the server's request open for the full timeout. + expect(request).not.toHaveBeenCalled(); + }); + + it("fails closed when the app advertised nothing at all", async () => { + await expect(requestAppElicitation(makeBridge({}), params)).rejects.toThrow( + /does not support elicitation/, + ); + }); + + it("propagates a bridge failure so the caller can fall back", async () => { + const bridge = makeBridge({ + appCapabilities: { elicitation: {} }, + request: vi.fn().mockRejectedValue(new Error("transport closed")), + }); + await expect(requestAppElicitation(bridge, params)).rejects.toThrow( + /transport closed/, + ); + }); + + it("keeps the answer timeout far above the SDK's request default", () => { + // The thing being waited on is a person, not a server. 60s (the SDK + // default) would abandon a user who paused to think. + expect(APP_ELICITATION_TIMEOUT_MS).toBeGreaterThan(60_000); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts new file mode 100644 index 00000000..e7907926 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/requestAppElicitation.ts @@ -0,0 +1,59 @@ +import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; +import type { ElicitRequest, ElicitResult } from "@modelcontextprotocol/client"; +import { ElicitResultSchema } from "@modelcontextprotocol/core"; +import { appAdvertisesElicitation } from "./appCapabilities"; + +/** + * How long the host waits for an app to answer an elicitation before giving up + * and falling back to the native UI. + * + * Deliberately generous: unlike a tool call, the thing being waited on is a + * *person* filling in a form, and the SDK's 60s request default would abandon + * a user who paused to think. Ten minutes bounds a bridge that will never + * answer (a wedged app, a closed tab) without ever racing a real user. + */ +export const APP_ELICITATION_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * Forward a form-mode `elicitation/create` to one specific running MCP App and + * return the app's standard `ElicitResult` (#1854). + * + * This is ext-apps' own `AppBridge.requestElicitation` from + * modelcontextprotocol/ext-apps#733 — same method, same params, same result — + * implemented against the bridge's generic `request()` because the released + * package (1.7.5) predates that PR. Replace the body with a call to + * `bridge.requestElicitation(params)` once a release containing #733 ships; + * nothing on the wire changes when that happens. + * + * Throwing is meaningful to every caller: it is the signal to fall back to the + * native elicitation UI. A user's `decline` or `cancel` is a *resolved* result, + * never a throw. + */ +export async function requestAppElicitation( + bridge: AppBridge, + params: ElicitRequest["params"], + timeoutMs: number = APP_ELICITATION_TIMEOUT_MS, +): Promise { + // Fail closed on the app's own advertisement rather than discovering it as a + // "-32601 method not found" ten minutes later: an app that never registered + // an elicitation handler is a fallback case, not an error case. + // NOT `bridge.getAppCapabilities()` directly: ext-apps 1.7.5 strips the + // `elicitation` key when it parses `ui/initialize`. See appCapabilities.ts. + if (!appAdvertisesElicitation(bridge)) { + throw new Error("App does not support elicitation"); + } + // ext-apps 1.7.5's send union (`AppRequest`) has no `ElicitRequest` member — + // that is precisely what #733 adds — so TypeScript sees no overlap with the + // existing members and a single `as` is rejected. The double cast is the + // documented-gap case: the runtime is a plain JSON-RPC send of the standard + // method with its standard params, verified against the app-side handler in + // the fixture and the bridge tests. Confined to this one line and removed + // with the ext-apps bump, when `bridge.requestElicitation(params)` replaces it. + const request = { + method: "elicitation/create", + params, + } as unknown as Parameters[0]; + return (await bridge.request(request, ElicitResultSchema, { + timeout: timeoutMs, + })) as ElicitResult; +} diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx index deaecd37..ab56111f 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.tsx @@ -662,7 +662,7 @@ export function AppsScreen({ { + it("queues a request and notifies subscribers", () => { + const controller = new AppElicitationController(); + const listener = vi.fn(); + controller.subscribe(listener); + + void request(controller, "a").catch(() => {}); + + expect(listener).toHaveBeenCalledTimes(1); + expect(controller.getEntries()).toHaveLength(1); + expect(controller.getEntries()[0]).toMatchObject({ + requestId: "a", + resourceUri: "ui://demo/pick.html", + }); + }); + + it("keeps the entries array identity stable between changes", () => { + // `useSyncExternalStore` re-renders on every getSnapshot identity change, + // so a fresh array per read would loop forever. + const controller = new AppElicitationController(); + expect(controller.getEntries()).toBe(controller.getEntries()); + }); + + it("settle resolves the render promise and drops the entry", async () => { + const controller = new AppElicitationController(); + const pending = request(controller, "a"); + controller.settle("a", { action: "accept", content: { choice: "x" } }); + await expect(pending).resolves.toEqual({ + action: "accept", + content: { choice: "x" }, + }); + expect(controller.getEntries()).toHaveLength(0); + }); + + it("fail rejects the render promise so the client falls back", async () => { + const controller = new AppElicitationController(); + const pending = request(controller, "a"); + controller.fail("a", new Error("sandbox unavailable")); + await expect(pending).rejects.toThrow(/sandbox unavailable/); + expect(controller.getEntries()).toHaveLength(0); + }); + + it("settling an unknown or already-settled id is a no-op", async () => { + const controller = new AppElicitationController(); + const pending = request(controller, "a"); + controller.settle("a", { action: "cancel" }); + await expect(pending).resolves.toEqual({ action: "cancel" }); + // A second settle must not throw — the modal's unmount and the client's + // abort can race, and both call in. + expect(() => controller.settle("a", { action: "decline" })).not.toThrow(); + expect(() => controller.fail("nope", new Error("x"))).not.toThrow(); + }); + + it("keeps concurrent requests independent", async () => { + const controller = new AppElicitationController(); + const first = request(controller, "a", "ui://demo/first.html"); + const second = request(controller, "b", "ui://demo/second.html"); + expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a", "b"]); + + controller.settle("b", { action: "accept", content: { choice: "b" } }); + expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a"]); + await expect(second).resolves.toMatchObject({ content: { choice: "b" } }); + + controller.settle("a", { action: "accept", content: { choice: "a" } }); + await expect(first).resolves.toMatchObject({ content: { choice: "a" } }); + }); + + it("drops and rejects an entry when the originating request aborts", async () => { + const controller = new AppElicitationController(); + const aborter = new AbortController(); + const pending = request( + controller, + "a", + "ui://demo/pick.html", + aborter.signal, + ); + expect(controller.getEntries()).toHaveLength(1); + aborter.abort(); + await expect(pending).rejects.toThrow(/aborted/); + expect(controller.getEntries()).toHaveLength(0); + }); + + it("never queues a request whose signal already aborted", async () => { + const controller = new AppElicitationController(); + const aborter = new AbortController(); + aborter.abort(); + await expect( + request(controller, "a", "ui://demo/pick.html", aborter.signal), + ).rejects.toThrow(/aborted/); + expect(controller.getEntries()).toHaveLength(0); + }); + + it("stops notifying an unsubscribed listener", () => { + const controller = new AppElicitationController(); + const listener = vi.fn(); + const unsubscribe = controller.subscribe(listener); + unsubscribe(); + void request(controller, "a").catch(() => {}); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/lib/appElicitationController.ts b/clients/web/src/lib/appElicitationController.ts new file mode 100644 index 00000000..d529cee2 --- /dev/null +++ b/clients/web/src/lib/appElicitationController.ts @@ -0,0 +1,99 @@ +import type { ElicitResult } from "@modelcontextprotocol/client"; +import type { + AppElicitationRenderer, + AppElicitationRequest, +} from "@inspector/core/mcp/appElicitation.js"; + +/** + * One app-rendered elicitation awaiting an answer, plus the settle functions + * for the `InspectorClient` promise it belongs to (#1854). + * + * The entry — not a "currently active app" — is what owns the renderer, so two + * concurrent elicitations each drive their own iframe and bridge and cannot + * resolve through each other's. + */ +export interface AppElicitationEntry extends AppElicitationRequest { + /** Hands the app's standard `ElicitResult` back to the server. */ + resolve: (result: ElicitResult) => void; + /** Asks `InspectorClient` to fall back to the native elicitation UI. */ + reject: (error: Error) => void; +} + +/** + * Bridges `InspectorClient`'s renderer callback — supplied at construction, + * long before any React tree exists — to the React component that actually + * mounts the app. + * + * The client is given {@link render} once and for all; the UI subscribes and + * re-renders as entries come and go. Without this indirection the renderer + * would have to be rebuilt (and the client reconstructed) whenever the host + * component remounted. + */ +export class AppElicitationController { + private entries: AppElicitationEntry[] = []; + private listeners = new Set<() => void>(); + + /** Current queue. Stable identity between changes, for `useSyncExternalStore`. */ + getEntries = (): AppElicitationEntry[] => this.entries; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + private emit(): void { + for (const listener of this.listeners) listener(); + } + + /** + * The {@link AppElicitationRenderer} handed to `InspectorClient`. Queues the + * request for the UI and resolves when {@link settle} or {@link fail} is + * called for it. + * + * An abort of the originating request (cancelled tool call, disconnect) drops + * the entry and rejects, so a modal can never outlive the request behind it. + */ + render: AppElicitationRenderer = (request: AppElicitationRequest) => + new Promise((resolve, reject) => { + const entry: AppElicitationEntry = { ...request, resolve, reject }; + const onAbort = () => { + this.remove(entry.requestId); + reject(new Error("App-rendered elicitation aborted")); + }; + if (request.signal.aborted) { + onAbort(); + return; + } + request.signal.addEventListener("abort", onAbort, { once: true }); + this.entries = [...this.entries, entry]; + this.emit(); + }); + + private take(requestId: string): AppElicitationEntry | undefined { + const entry = this.entries.find((e) => e.requestId === requestId); + if (entry) this.remove(requestId); + return entry; + } + + private remove(requestId: string): void { + const next = this.entries.filter((e) => e.requestId !== requestId); + if (next.length === this.entries.length) return; + this.entries = next; + this.emit(); + } + + /** + * Complete an elicitation with the app's result. `decline` and `cancel` are + * completions too — they are returned to the server, not fallen back on. + */ + settle(requestId: string, result: ElicitResult): void { + this.take(requestId)?.resolve(result); + } + + /** Give up on the app and let the native elicitation UI take the request. */ + fail(requestId: string, error: Error): void { + this.take(requestId)?.reject(error); + } +} diff --git a/clients/web/src/test/core/mcp/appElicitation.test.ts b/clients/web/src/test/core/mcp/appElicitation.test.ts new file mode 100644 index 00000000..cf433f16 --- /dev/null +++ b/clients/web/src/test/core/mcp/appElicitation.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from "vitest"; +import type { + ClientCapabilities, + ElicitRequest, + ElicitResult, + ServerCapabilities, +} from "@modelcontextprotocol/client"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; +import { + getElicitationUiResourceUri, + getUiClientCapability, + getUiServerCapability, + isFormElicitation, + supportsAppElicitation, + validateAppElicitResult, +} from "@inspector/core/mcp/appElicitation.js"; +import { + MCP_APP_MIME_TYPE, + UI_EXTENSION_KEY, +} from "@inspector/core/mcp/extensions.js"; + +/** A client that satisfies all three client-side negotiation gates. */ +function eligibleClient(): ClientCapabilities { + return { + elicitation: { form: {} }, + extensions: { + [UI_EXTENSION_KEY]: { + mimeTypes: [MCP_APP_MIME_TYPE], + elicitation: {}, + }, + }, + }; +} + +/** A server that advertises the nested MCP Apps elicitation setting. */ +function eligibleServer(): ServerCapabilities { + return { extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } } }; +} + +function formParams( + extra: Partial = {}, +): ElicitRequest["params"] { + return { + message: "Choose an option", + requestedSchema: { + type: "object", + properties: { choice: { type: "string" } }, + required: ["choice"], + }, + ...extra, + } as ElicitRequest["params"]; +} + +describe("appElicitation negotiation (#1854)", () => { + describe("capability readers", () => { + it("reads the UI block from either side", () => { + expect(getUiClientCapability(eligibleClient())).toEqual({ + mimeTypes: [MCP_APP_MIME_TYPE], + elicitation: {}, + }); + expect(getUiServerCapability(eligibleServer())).toEqual({ + elicitation: {}, + }); + }); + + it("returns undefined for absent, null and non-object capabilities", () => { + expect(getUiClientCapability(undefined)).toBeUndefined(); + expect(getUiClientCapability(null)).toBeUndefined(); + expect(getUiClientCapability({})).toBeUndefined(); + expect(getUiServerCapability({ extensions: {} })).toBeUndefined(); + expect( + getUiClientCapability({ + extensions: { [UI_EXTENSION_KEY]: null }, + } as unknown as ClientCapabilities), + ).toBeUndefined(); + }); + }); + + describe("supportsAppElicitation", () => { + it("is true only when all four gates pass", () => { + expect(supportsAppElicitation(eligibleClient(), eligibleServer())).toBe( + true, + ); + }); + + it("is false without the core form elicitation capability", () => { + const client = eligibleClient(); + delete client.elicitation; + expect(supportsAppElicitation(client, eligibleServer())).toBe(false); + }); + + it("is false when the client advertised only url-mode elicitation", () => { + const client = { ...eligibleClient(), elicitation: { url: {} } }; + expect(supportsAppElicitation(client, eligibleServer())).toBe(false); + }); + + it("is false when the client does not accept the MCP App MIME type", () => { + const client: ClientCapabilities = { + elicitation: { form: {} }, + extensions: { + [UI_EXTENSION_KEY]: { mimeTypes: ["text/html"], elicitation: {} }, + }, + }; + expect(supportsAppElicitation(client, eligibleServer())).toBe(false); + }); + + it("is false when the client advertises the MIME type but not elicitation", () => { + // The specific "MIME type alone is not sufficient" case: this is exactly + // what a CLI/TUI client looks like, and it must not be offered an app. + const client: ClientCapabilities = { + elicitation: { form: {} }, + extensions: { + [UI_EXTENSION_KEY]: { mimeTypes: [MCP_APP_MIME_TYPE] }, + }, + }; + expect(supportsAppElicitation(client, eligibleServer())).toBe(false); + }); + + it("is false when the server did not advertise it", () => { + expect(supportsAppElicitation(eligibleClient(), {})).toBe(false); + expect( + supportsAppElicitation(eligibleClient(), { + extensions: { [UI_EXTENSION_KEY]: {} }, + }), + ).toBe(false); + expect(supportsAppElicitation(eligibleClient(), undefined)).toBe(false); + }); + }); + + describe("getElicitationUiResourceUri", () => { + it("returns the URI from _meta.ui.resourceUri", () => { + expect( + getElicitationUiResourceUri( + formParams({ _meta: { ui: { resourceUri: "ui://demo/pick.html" } } }), + ), + ).toBe("ui://demo/pick.html"); + }); + + it("returns undefined when no app is attached", () => { + expect(getElicitationUiResourceUri(formParams())).toBeUndefined(); + expect( + getElicitationUiResourceUri(formParams({ _meta: {} })), + ).toBeUndefined(); + expect( + getElicitationUiResourceUri(formParams({ _meta: { ui: "nope" } })), + ).toBeUndefined(); + expect( + getElicitationUiResourceUri(formParams({ _meta: { ui: null } })), + ).toBeUndefined(); + expect( + getElicitationUiResourceUri(formParams({ _meta: { ui: {} } })), + ).toBeUndefined(); + }); + + it("throws on a non-string resourceUri", () => { + expect(() => + getElicitationUiResourceUri( + formParams({ _meta: { ui: { resourceUri: 42 } } }), + ), + ).toThrow(/must be a string/); + }); + + it.each([ + ["relative", "demo/pick.html"], + ["wrong scheme", "https://example.com/pick.html"], + ["scheme with no host", "ui:///pick.html"], + ["bare scheme", "ui:pick.html"], + ["not a URL at all", " "], + ])("throws on a %s URI", (_label, uri) => { + expect(() => + getElicitationUiResourceUri( + formParams({ _meta: { ui: { resourceUri: uri } } }), + ), + ).toThrow(/absolute ui:\/\/ URI/); + }); + }); + + describe("isFormElicitation", () => { + it("treats an omitted mode as form", () => { + expect(isFormElicitation(formParams())).toBe(true); + expect(isFormElicitation(formParams({ mode: "form" }))).toBe(true); + }); + + it("rejects url mode", () => { + expect(isFormElicitation(formParams({ mode: "url" }))).toBe(false); + }); + }); + + describe("validateAppElicitResult", () => { + const provider = new AjvJsonSchemaValidator(); + const params = formParams(); + + it("accepts a well-formed accept", () => { + expect( + validateAppElicitResult(provider, params, { + action: "accept", + content: { choice: "option-a" }, + }), + ).toBeUndefined(); + }); + + it("accepts decline and cancel with no content", () => { + expect( + validateAppElicitResult(provider, params, { action: "decline" }), + ).toBeUndefined(); + expect( + validateAppElicitResult(provider, params, { action: "cancel" }), + ).toBeUndefined(); + }); + + it("rejects a non-object result", () => { + expect( + validateAppElicitResult( + provider, + params, + null as unknown as ElicitResult, + ), + ).toMatch(/non-object/); + }); + + it("rejects an unknown action", () => { + expect( + validateAppElicitResult(provider, params, { + action: "maybe", + } as unknown as ElicitResult), + ).toMatch(/unknown elicitation action/); + }); + + it("rejects an accept with no usable content", () => { + expect( + validateAppElicitResult(provider, params, { + action: "accept", + } as ElicitResult), + ).toMatch(/without a content object/); + expect( + validateAppElicitResult(provider, params, { + action: "accept", + content: [] as unknown as Record, + } as ElicitResult), + ).toMatch(/without a content object/); + }); + + it("rejects content that does not match the requested schema", () => { + expect( + validateAppElicitResult(provider, params, { + action: "accept", + content: { choice: 7 } as unknown as Record, + } as ElicitResult), + ).toMatch(/does not match the requested schema/); + }); + + it("skips validation when the request declared no usable schema", () => { + const noSchema = { message: "hi" } as ElicitRequest["params"]; + expect( + validateAppElicitResult(provider, noSchema, { + action: "accept", + content: { anything: true }, + }), + ).toBeUndefined(); + }); + + it("does not reject a result over a schema the validator cannot compile", () => { + const badSchema = formParams({ + requestedSchema: { type: "object", properties: { a: { type: 9 } } }, + } as unknown as Partial); + expect( + validateAppElicitResult(provider, badSchema, { + action: "accept", + content: { a: 1 }, + }), + ).toBeUndefined(); + }); + }); +}); diff --git a/clients/web/src/test/core/mcp/extensions.test.ts b/clients/web/src/test/core/mcp/extensions.test.ts index e027c9a3..5a2bbb26 100644 --- a/clients/web/src/test/core/mcp/extensions.test.ts +++ b/clients/web/src/test/core/mcp/extensions.test.ts @@ -145,4 +145,42 @@ describe("extensions (#1738, #1740)", () => { expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} }); }); }); + + describe("app-rendered elicitation opt-in (#1854)", () => { + it("does not advertise the nested elicitation setting by default", () => { + const map = buildClientExtensions({ enterpriseManaged: false }); + expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT); + }); + + it("nests `elicitation` inside the UI extension when opted in", () => { + const map = buildClientExtensions({ + enterpriseManaged: false, + appElicitation: true, + }); + expect(map[UI_EXTENSION_KEY]).toEqual({ + ...UI_ADVERTISEMENT, + elicitation: {}, + }); + // A nested setting, NOT a second extension — the contract is explicit + // that no new extension id is introduced. + expect(Object.keys(map)).toEqual([TASKS_EXTENSION_KEY, UI_EXTENSION_KEY]); + }); + + it("advertises nothing when the UI extension itself is turned off", () => { + const map = buildClientExtensions({ + enterpriseManaged: false, + appElicitation: true, + advertised: { [UI_EXTENSION_KEY]: false }, + }); + expect(map).not.toHaveProperty(UI_EXTENSION_KEY); + }); + + it("does not mutate the shared registry advertisement", () => { + buildClientExtensions({ enterpriseManaged: false, appElicitation: true }); + const ui = ADVERTISABLE_EXTENSIONS.find( + (e) => e.key === UI_EXTENSION_KEY, + ); + expect(ui?.advertisement).toEqual(UI_ADVERTISEMENT); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts new file mode 100644 index 00000000..fd12401e --- /dev/null +++ b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts @@ -0,0 +1,431 @@ +import { describe, it, expect, vi } from "vitest"; +import type { + ClientCapabilities, + ElicitResult, + JSONRPCMessage, + ServerCapabilities, + Transport, +} from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { + AppElicitationRenderer, + AppElicitationRequest, +} from "@inspector/core/mcp/appElicitation.js"; +import { + MCP_APP_MIME_TYPE, + UI_EXTENSION_KEY, +} from "@inspector/core/mcp/extensions.js"; + +/** + * Routing coverage for app-rendered form elicitations (#1854). + * + * Everything here drives the REAL inbound `elicitation/create` handler over a + * fake transport, because the whole feature is a decision made inside that + * handler: which of two user interfaces answers a server's request. Asserting + * on the reply frame — rather than on an internal — is what proves the server + * gets the app's standard `ElicitResult` unchanged. + */ + +const APP_URI = "ui://demo/choose-option.html"; + +const REQUESTED_SCHEMA = { + type: "object" as const, + properties: { choice: { type: "string" as const } }, + required: ["choice"], +}; + +/** Server capabilities advertising the nested MCP Apps elicitation setting. */ +const APP_SERVER_CAPABILITIES: ServerCapabilities = { + extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } }, +}; + +class ElicitTransport implements Transport { + onmessage?: (message: JSONRPCMessage) => void; + onclose?: () => void; + onerror?: (error: Error) => void; + + private readonly waiters = new Map< + string | number, + (m: JSONRPCMessage) => void + >(); + + private readonly serverCapabilities: ServerCapabilities; + + constructor( + serverCapabilities: ServerCapabilities = APP_SERVER_CAPABILITIES, + ) { + this.serverCapabilities = serverCapabilities; + } + + async start(): Promise {} + async close(): Promise {} + + async send(message: JSONRPCMessage): Promise { + if ( + "method" in message && + message.method === "initialize" && + "id" in message + ) { + const params = message.params as { protocolVersion: string }; + this.deliver({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: this.serverCapabilities, + serverInfo: { name: "app-elicit-server", version: "1.0.0" }, + }, + }); + return; + } + // A reply to something we injected. + if ( + "id" in message && + message.id !== undefined && + ("result" in message || "error" in message) + ) { + this.waiters.get(message.id)?.(message); + this.waiters.delete(message.id); + } + } + + /** Send an `elicitation/create` and resolve with the client's reply frame. */ + elicit( + id: number, + params: Record, + timeoutMs = 2000, + ): Promise { + const reply = new Promise((resolve) => { + this.waiters.set(id, resolve); + }); + this.deliver({ jsonrpc: "2.0", id, method: "elicitation/create", params }); + let timer: ReturnType; + return Promise.race([ + reply, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`No reply to elicitation ${id}`)), + timeoutMs, + ); + }), + ]).finally(() => clearTimeout(timer)); + } + + private deliver(message: JSONRPCMessage): void { + this.onmessage?.(message); + } +} + +function appParams(overrides: Record = {}) { + return { + message: "Choose an option", + requestedSchema: REQUESTED_SCHEMA, + _meta: { ui: { resourceUri: APP_URI } }, + ...overrides, + }; +} + +async function connectClient(options: { + transport: ElicitTransport; + appElicitation?: AppElicitationRenderer; + elicit?: boolean | { form?: boolean; url?: boolean }; +}) { + const client = new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + { + environment: { transport: () => ({ transport: options.transport }) }, + elicit: options.elicit ?? { form: true }, + ...(options.appElicitation && { appElicitation: options.appElicitation }), + }, + ); + await client.connect(); + return client; +} + +/** The `io.modelcontextprotocol/ui` block the client advertised. */ +function advertisedUi(client: InspectorClient) { + const capabilities = ( + client as unknown as { clientCapabilities: ClientCapabilities } + ).clientCapabilities; + return capabilities.extensions?.[UI_EXTENSION_KEY] as + | { mimeTypes?: string[]; elicitation?: object } + | undefined; +} + +describe("app-rendered elicitation routing (#1854)", () => { + describe("capability advertisement", () => { + it("advertises the nested elicitation setting when a renderer is supplied", async () => { + const client = await connectClient({ + transport: new ElicitTransport(), + appElicitation: async () => ({ action: "cancel" }), + }); + expect(advertisedUi(client)).toEqual({ + mimeTypes: [MCP_APP_MIME_TYPE], + elicitation: {}, + }); + await client.disconnect(); + }); + + it("does not advertise it on a client with no renderer (CLI/TUI)", async () => { + // The MIME type alone is what CLI and TUI advertise, and it must stay + // that way: they know the type but cannot host an app. + const client = await connectClient({ transport: new ElicitTransport() }); + expect(advertisedUi(client)).toEqual({ mimeTypes: [MCP_APP_MIME_TYPE] }); + await client.disconnect(); + }); + + it("omits both capabilities when form elicitation is disabled", async () => { + const client = await connectClient({ + transport: new ElicitTransport(), + appElicitation: async () => ({ action: "cancel" }), + elicit: { url: true }, + }); + const capabilities = ( + client as unknown as { clientCapabilities: ClientCapabilities } + ).clientCapabilities; + expect(capabilities.elicitation?.form).toBeUndefined(); + expect(advertisedUi(client)?.elicitation).toBeUndefined(); + await client.disconnect(); + }); + }); + + describe("negotiated happy path", () => { + it("returns the app's accept result to the server, without queueing a native request", async () => { + const transport = new ElicitTransport(); + const seen: AppElicitationRequest[] = []; + const client = await connectClient({ + transport, + appElicitation: async (request) => { + seen.push(request); + return { action: "accept", content: { choice: "option-a" } }; + }, + }); + + const reply = await transport.elicit(1, appParams()); + + expect(reply).toMatchObject({ + id: 1, + result: { action: "accept", content: { choice: "option-a" } }, + }); + // Request-scoped: the renderer is told exactly which resource and which + // params, and gets a distinct id per request. + expect(seen).toHaveLength(1); + expect(seen[0].resourceUri).toBe(APP_URI); + expect(seen[0].params.message).toBe("Choose an option"); + // The native queue was never opened — the whole point of the routing. + expect(client.getPendingElicitations()).toHaveLength(0); + await client.disconnect(); + }); + + it.each([["decline"], ["cancel"]] as const)( + "returns an explicit %s without opening the native UI", + async (action) => { + const transport = new ElicitTransport(); + const client = await connectClient({ + transport, + appElicitation: async () => ({ action }) as ElicitResult, + }); + const reply = await transport.elicit(2, appParams()); + expect(reply).toMatchObject({ id: 2, result: { action } }); + expect(client.getPendingElicitations()).toHaveLength(0); + await client.disconnect(); + }, + ); + + it("gives concurrent requests distinct ids and never crosses their results", async () => { + const transport = new ElicitTransport(); + const settle = new Map void>(); + const byUri = new Map(); + const client = await connectClient({ + transport, + appElicitation: (request) => + new Promise((resolve) => { + byUri.set(request.resourceUri, request.requestId); + settle.set(request.requestId, resolve); + }), + }); + + const first = transport.elicit( + 10, + appParams({ _meta: { ui: { resourceUri: "ui://demo/first.html" } } }), + ); + const second = transport.elicit( + 11, + appParams({ _meta: { ui: { resourceUri: "ui://demo/second.html" } } }), + ); + await vi.waitFor(() => expect(settle.size).toBe(2)); + + const firstId = byUri.get("ui://demo/first.html")!; + const secondId = byUri.get("ui://demo/second.html")!; + expect(firstId).not.toBe(secondId); + // Answer them out of order: an implementation keyed on "the active app" + // rather than on the request would hand each answer to the wrong request. + settle.get(secondId)!({ action: "accept", content: { choice: "b" } }); + settle.get(firstId)!({ action: "accept", content: { choice: "a" } }); + + expect(await first).toMatchObject({ + id: 10, + result: { content: { choice: "a" } }, + }); + expect(await second).toMatchObject({ + id: 11, + result: { content: { choice: "b" } }, + }); + await client.disconnect(); + }); + }); + + describe("native fallback", () => { + /** + * Every fallback case asserts the same shape: the renderer is not used (or + * fails), and the request lands in the native pending queue instead, which + * we then answer to keep the server from waiting. + */ + async function expectNativeFallback( + transport: ElicitTransport, + client: InspectorClient, + id: number, + params: Record, + ) { + const reply = transport.elicit(id, params); + await vi.waitFor(() => + expect(client.getPendingElicitations()).toHaveLength(1), + ); + // `respond` settles the queued request; its own send is fire-and-forget. + void client + .getPendingElicitations()[0] + .respond({ action: "accept", content: { choice: "native" } }); + expect(await reply).toMatchObject({ + id, + result: { content: { choice: "native" } }, + }); + } + + it("falls back when the server did not advertise the capability", async () => { + const transport = new ElicitTransport({}); + const renderer = vi.fn(); + const client = await connectClient({ + transport, + appElicitation: renderer as unknown as AppElicitationRenderer, + }); + await expectNativeFallback(transport, client, 20, appParams()); + expect(renderer).not.toHaveBeenCalled(); + await client.disconnect(); + }); + + it("falls back when the request names no app", async () => { + const transport = new ElicitTransport(); + const renderer = vi.fn(); + const client = await connectClient({ + transport, + appElicitation: renderer as unknown as AppElicitationRenderer, + }); + await expectNativeFallback( + transport, + client, + 21, + appParams({ _meta: undefined }), + ); + expect(renderer).not.toHaveBeenCalled(); + await client.disconnect(); + }); + + it("falls back on malformed metadata", async () => { + const transport = new ElicitTransport(); + const renderer = vi.fn(); + const client = await connectClient({ + transport, + appElicitation: renderer as unknown as AppElicitationRenderer, + }); + await expectNativeFallback( + transport, + client, + 22, + appParams({ _meta: { ui: { resourceUri: "not-a-ui-uri" } } }), + ); + expect(renderer).not.toHaveBeenCalled(); + await client.disconnect(); + }); + + it("falls back for a url-mode elicitation", async () => { + const transport = new ElicitTransport(); + const renderer = vi.fn(); + const client = await connectClient({ + transport, + appElicitation: renderer as unknown as AppElicitationRenderer, + elicit: { form: true, url: true }, + }); + // A url-mode request carries no `requestedSchema` — it is a different + // params shape, and only `form` is app-renderable. + await expectNativeFallback(transport, client, 23, { + mode: "url", + message: "Sign in to continue", + url: "https://example.com/form", + elicitationId: "url-1", + _meta: { ui: { resourceUri: APP_URI } }, + }); + expect(renderer).not.toHaveBeenCalled(); + await client.disconnect(); + }); + + it("falls back when the renderer rejects (resource/sandbox/bridge failure, timeout)", async () => { + const transport = new ElicitTransport(); + const client = await connectClient({ + transport, + appElicitation: async () => { + throw new Error("App did not initialize in time"); + }, + }); + await expectNativeFallback(transport, client, 24, appParams()); + await client.disconnect(); + }); + + it("falls back when the app returns an invalid result", async () => { + const transport = new ElicitTransport(); + const client = await connectClient({ + transport, + appElicitation: async () => + ({ action: "sure" }) as unknown as ElicitResult, + }); + await expectNativeFallback(transport, client, 25, appParams()); + await client.disconnect(); + }); + + it("falls back when accepted content fails the requested schema", async () => { + const transport = new ElicitTransport(); + const client = await connectClient({ + transport, + appElicitation: async () => ({ + action: "accept", + content: { choice: 7 } as unknown as Record, + }), + }); + await expectNativeFallback(transport, client, 26, appParams()); + await client.disconnect(); + }); + }); + + describe("teardown", () => { + it("aborts a pending app elicitation on disconnect", async () => { + const transport = new ElicitTransport(); + let aborted = false; + const client = await connectClient({ + transport, + appElicitation: (request) => + new Promise((_resolve, reject) => { + request.signal.addEventListener("abort", () => { + aborted = true; + reject(new Error("aborted")); + }); + }), + }); + void transport.elicit(30, appParams()).catch(() => {}); + await vi.waitFor(() => expect(aborted).toBe(false)); + await client.disconnect(); + await vi.waitFor(() => expect(aborted).toBe(true)); + // An aborted request must NOT resurface in the native queue: the user + // abandoned it, and the connection it belonged to is gone. + expect(client.getPendingElicitations()).toHaveLength(0); + }); + }); +}); diff --git a/clients/web/src/test/integration/mcp/appElicitation.test.ts b/clients/web/src/test/integration/mcp/appElicitation.test.ts new file mode 100644 index 00000000..394e8efa --- /dev/null +++ b/clients/web/src/test/integration/mcp/appElicitation.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import type { ElicitResult } from "@modelcontextprotocol/client"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import type { AppElicitationRequest } from "@inspector/core/mcp/appElicitation.js"; +import { + APP_ELICITATION_URI, + createAppElicitationResource, + createAppElicitationTool, + createTestServerHttp, + createTestServerInfo, + type TestServerHttp, +} from "@modelcontextprotocol/inspector-test-server"; + +/** + * Live coverage of app-rendered form elicitations (#1854) against the public + * fixture, over a real transport. + * + * The unit tests drive the handler with a hand-written frame; this drives the + * real `app_choose_option` tool on the real composable server, so the whole + * path is exercised: the server's `_meta.ui.resourceUri`, its advertised + * `io.modelcontextprotocol/ui.elicitation` capability, the SDK's own + * `elicitInput` serialization (which is where a dropped `_meta` would hide), + * and the Inspector's routing decision. + * + * The renderer stands in for the web client's sandbox — everything above it is + * production code. + */ +describe("app-rendered elicitation, live server (#1854)", () => { + let client: InspectorClient | null = null; + const servers: TestServerHttp[] = []; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // ignore + } + client = null; + } + while (servers.length) { + try { + await servers.pop()?.stop(); + } catch { + // ignore + } + } + }); + + /** + * The fixture server. `appElicitation` is the server half of the + * negotiation; omitting it is the "not negotiated" scenario. + */ + async function startServer(appElicitation: boolean): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("app-elicit-test", "1.0.0"), + tools: [createAppElicitationTool()], + resources: [createAppElicitationResource()], + ...(appElicitation && { appElicitation }), + }); + await started.start(); + servers.push(started); + return started; + } + + /** The fixture tool definition, as the server reports it in `tools/list`. */ + async function appTool(connected: InspectorClient) { + const { tools } = await connected.listTools(); + const tool = tools.find((t) => t.name === "app_choose_option"); + if (!tool) throw new Error("app_choose_option missing from tools/list"); + return tool; + } + + async function connect( + url: string, + renderer?: (request: AppElicitationRequest) => Promise, + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode }, + elicit: { form: true }, + ...(renderer && { appElicitation: renderer }), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + it("routes the server's elicitation to the app and returns its result", async () => { + const started = await startServer(true); + const seen: AppElicitationRequest[] = []; + const connected = await connect(started.url, async (request) => { + seen.push(request); + return { action: "accept", content: { choice: "option-a" } }; + }); + + const result = await connected.callTool(await appTool(connected), { + prompt: "Choose option A or B.", + }); + + // The tool echoes the ElicitResult it received, so this asserts the app's + // standard result reached the SERVER — not merely the host. + const text = JSON.stringify(result.result?.content); + expect(text).toContain('\\"action\\":\\"accept\\"'); + expect(text).toContain("option-a"); + + expect(seen).toHaveLength(1); + // The URI the SERVER named, carried on the request's own `_meta`. + expect(seen[0].resourceUri).toBe(APP_ELICITATION_URI); + expect(seen[0].params.message).toBe("Choose option A or B."); + // The native queue never opened. + expect(connected.getPendingElicitations()).toHaveLength(0); + }); + + it("returns an app decline to the server without opening the native UI", async () => { + const started = await startServer(true); + const connected = await connect(started.url, async () => ({ + action: "decline", + })); + const result = await connected.callTool(await appTool(connected), {}); + expect(JSON.stringify(result.result?.content)).toContain("decline"); + expect(connected.getPendingElicitations()).toHaveLength(0); + }); + + it("falls back to the native UI when the server did not advertise the capability", async () => { + // Same tool, same `_meta.ui.resourceUri` — only the server's advertisement + // differs. This is the over-claiming failure mode: a client that renders an + // app here would strand every user of a server that never opted in. + const started = await startServer(false); + let rendererCalls = 0; + const connected = await connect(started.url, async () => { + rendererCalls++; + return { action: "cancel" }; + }); + + const tool = await appTool(connected); + const call = connected.callTool(tool, {}); + await vi.waitFor(() => + expect(connected.getPendingElicitations()).toHaveLength(1), + ); + await connected + .getPendingElicitations()[0] + .respond({ action: "accept", content: { choice: "option-b" } }); + + const result = await call; + expect(JSON.stringify(result.result?.content)).toContain("option-b"); + expect(rendererCalls).toBe(0); + }); + + it("falls back when the client cannot host an app (no renderer)", async () => { + // The CLI/TUI shape: the server offers an app, the client never advertised + // the nested capability, so the server's request is answered natively. + const started = await startServer(true); + const connected = await connect(started.url); + const tool = await appTool(connected); + const call = connected.callTool(tool, {}); + await vi.waitFor(() => + expect(connected.getPendingElicitations()).toHaveLength(1), + ); + await connected + .getPendingElicitations()[0] + .respond({ action: "accept", content: { choice: "option-a" } }); + await expect(call).resolves.toBeDefined(); + }); +}); diff --git a/core/mcp/appElicitation.ts b/core/mcp/appElicitation.ts new file mode 100644 index 00000000..adfff56c --- /dev/null +++ b/core/mcp/appElicitation.ts @@ -0,0 +1,238 @@ +import type { + ClientCapabilities, + ElicitRequest, + ElicitResult, + JsonSchemaType, + jsonSchemaValidator, + ServerCapabilities, +} from "@modelcontextprotocol/client"; +import { MCP_APP_MIME_TYPE, UI_EXTENSION_KEY } from "./extensions.js"; + +/** + * App-rendered form elicitations (#1854). + * + * A server may attach an MCP App resource to a standard `elicitation/create` + * request; a host that can run MCP Apps renders that app and returns the app's + * ordinary `ElicitResult` to the server. No second extension, no custom method, + * and no custom result shape are introduced — the only new wire surface is a + * nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` + * extension on each side, plus `_meta.ui.resourceUri` on the request. + * + * The helpers here mirror the ext-apps draft (modelcontextprotocol/ext-apps#733, + * SEP-3118) so the Inspector speaks exactly the proposed protocol. They are + * declared locally only because the released `@modelcontextprotocol/ext-apps` + * (1.7.5) predates that PR and exports none of them; replace + * {@link supportsAppElicitation} / {@link getElicitationUiResourceUri} with the + * package's `/server` exports once a release containing #733 ships. + */ + +/** + * MCP Apps extension settings advertised by a client, as far as app-rendered + * elicitation cares. Mirrors ext-apps' `McpUiClientCapabilities`. + */ +export interface UiClientCapabilities { + mimeTypes?: string[]; + elicitation?: object; +} + +/** + * MCP Apps extension settings advertised by a server. Mirrors ext-apps' + * `McpUiServerCapabilities` (introduced by #733): a server sets `elicitation` + * to declare it may attach an App resource to a form elicitation. + */ +export interface UiServerCapabilities { + elicitation?: object; +} + +/** Reads the `io.modelcontextprotocol/ui` block out of either side's capabilities. */ +function uiExtension( + capabilities: { extensions?: Record } | null | undefined, +): Record | undefined { + const ext = capabilities?.extensions?.[UI_EXTENSION_KEY]; + return typeof ext === "object" && ext !== null + ? (ext as Record) + : undefined; +} + +/** The MCP Apps settings a client advertised, or `undefined`. */ +export function getUiClientCapability( + capabilities: ClientCapabilities | null | undefined, +): UiClientCapabilities | undefined { + return uiExtension(capabilities) as UiClientCapabilities | undefined; +} + +/** The MCP Apps settings a server advertised, or `undefined`. */ +export function getUiServerCapability( + capabilities: ServerCapabilities | null | undefined, +): UiServerCapabilities | undefined { + return uiExtension(capabilities) as UiServerCapabilities | undefined; +} + +/** + * Whether both peers negotiated app-rendered form elicitation. All of the + * protocol's conditions except the per-request `_meta` are checked here: + * + * 1. the client advertised core form elicitation (`elicitation.form`); + * 2. the client advertised the MCP Apps MIME type; + * 3. the client advertised the nested MCP Apps `elicitation` setting; + * 4. the server advertised the nested MCP Apps `elicitation` setting. + * + * A MIME-type match alone is deliberately not sufficient — a client that can + * render App *tools* cannot necessarily resolve an elicitation through a bridge. + */ +export function supportsAppElicitation( + clientCapabilities: ClientCapabilities | null | undefined, + serverCapabilities: ServerCapabilities | null | undefined, +): boolean { + const clientUi = getUiClientCapability(clientCapabilities); + const serverUi = getUiServerCapability(serverCapabilities); + return Boolean( + clientCapabilities?.elicitation?.form && + clientUi?.mimeTypes?.includes(MCP_APP_MIME_TYPE) && + clientUi.elicitation && + serverUi?.elicitation, + ); +} + +const ELICITATION_UI_URI_ERROR = + "Elicitation UI resourceUri must be an absolute ui:// URI"; + +/** + * Rejects anything that is not an absolute `ui://host/...` URI. Matches the + * ext-apps#733 validator: a bare scheme, a relative reference, or a non-`ui` + * scheme are all unusable and must not reach the renderer. + */ +function validateElicitationUiResourceUri(resourceUri: string): void { + let parsed: URL; + try { + parsed = new URL(resourceUri); + } catch { + throw new Error(ELICITATION_UI_URI_ERROR); + } + if ( + parsed.protocol !== "ui:" || + !/^ui:\/\//i.test(resourceUri) || + parsed.host.length === 0 + ) { + throw new Error(ELICITATION_UI_URI_ERROR); + } +} + +/** + * Reads `_meta.ui.resourceUri` off an `elicitation/create` request. + * + * Returns `undefined` when the server attached no App (the ordinary case — the + * native elicitation UI handles it). Throws when the metadata is present but + * unusable, so the caller can log the server bug and still fall back rather + * than rendering something arbitrary. + */ +export function getElicitationUiResourceUri( + params: ElicitRequest["params"], +): string | undefined { + const ui = (params._meta as { ui?: unknown } | undefined)?.ui; + if (typeof ui !== "object" || ui === null) return undefined; + const resourceUri = (ui as Record).resourceUri; + if (resourceUri === undefined) return undefined; + if (typeof resourceUri !== "string") { + throw new Error("Elicitation UI resourceUri must be a string"); + } + validateElicitationUiResourceUri(resourceUri); + return resourceUri; +} + +/** + * Only `form` mode is app-renderable; an omitted mode IS form (the mode field + * post-dates form elicitation). `url` mode keeps its existing path. + */ +export function isFormElicitation(params: ElicitRequest["params"]): boolean { + const mode = (params as { mode?: unknown }).mode; + return mode === undefined || mode === "form"; +} + +/** The three actions a completed elicitation may report. */ +const ELICIT_ACTIONS = ["accept", "decline", "cancel"] as const; + +/** + * Validates what an app returned before it is handed back to the server. + * + * Returns a human-readable reason when the value is not a usable + * `ElicitResult` — an unknown action, an `accept` with no content object, or + * content that fails the request's own `requestedSchema` — and `undefined` when + * it is fine. The value is untrusted (it came from sandboxed app code), so the + * runtime checks stand regardless of the declared type. + * + * A failure here is a fallback trigger, not a protocol error: the host drops to + * the native elicitation UI rather than sending the server something that does + * not match what it asked for. + */ +export function validateAppElicitResult( + provider: jsonSchemaValidator, + params: ElicitRequest["params"], + result: ElicitResult, +): string | undefined { + if (typeof result !== "object" || result === null) { + return "App returned a non-object elicitation result"; + } + const action = (result as { action?: unknown }).action; + if (!ELICIT_ACTIONS.includes(action as (typeof ELICIT_ACTIONS)[number])) { + return `App returned an unknown elicitation action: ${JSON.stringify(action)}`; + } + // decline / cancel carry no content — they are complete as they stand. + if (action !== "accept") return undefined; + const content = (result as { content?: unknown }).content; + if ( + typeof content !== "object" || + content === null || + Array.isArray(content) + ) { + return "App accepted the elicitation without a content object"; + } + const schema = (params as { requestedSchema?: unknown }).requestedSchema; + if (typeof schema !== "object" || schema === null) return undefined; + try { + // `requestedSchema` is the SDK's own object-schema shape and `JsonSchemaType` + // the validator provider's third-party JSON Schema interface — structurally + // compatible, nominally unrelated, exactly as in `validateToolOutput`. + const validate = provider.getValidator(schema as JsonSchemaType); + const validation = validate(content); + return validation.valid + ? undefined + : `App content does not match the requested schema: ${validation.errorMessage}`; + } catch { + // A schema the validator cannot compile is the server's problem, not the + // app's; don't reject a result over it (mirrors `validateToolOutput`). + return undefined; + } +} + +/** + * One app-rendered elicitation, scoped to the originating request. + * + * `requestId` is what makes the association request-scoped: the host keys its + * renderer/bridge by it, so two concurrent elicitations for different resource + * URIs can never resolve through each other's bridges. + */ +export interface AppElicitationRequest { + /** Unique per originating `elicitation/create` request. */ + requestId: string; + /** The validated absolute `ui://` URI the app is loaded from. */ + resourceUri: string; + /** The original request params, forwarded through the bridge unchanged. */ + params: ElicitRequest["params"]; + /** Aborts when the originating request is cancelled or the client disconnects. */ + signal: AbortSignal; +} + +/** + * Host-supplied renderer for {@link AppElicitationRequest}s. Only a client that + * can actually host MCP Apps (today: the web client, when its sandbox renderer + * is available) provides one — providing it is what opts the client into + * advertising the nested MCP Apps `elicitation` capability. + * + * Resolves with the app's standard `ElicitResult`. Rejecting is a request to + * fall back to the native elicitation UI; it must not be used to signal a user + * decision, since `decline` and `cancel` are themselves completed elicitations. + */ +export type AppElicitationRenderer = ( + request: AppElicitationRequest, +) => Promise; diff --git a/core/mcp/extensions.ts b/core/mcp/extensions.ts index db7e0e12..e3c32f08 100644 --- a/core/mcp/extensions.ts +++ b/core/mcp/extensions.ts @@ -98,6 +98,19 @@ export interface BuildClientExtensionsInput { * over the registry's `defaultAdvertised`; an absent key falls back to it. */ advertised?: Record; + /** + * True when this client can render an MCP App and resolve an + * `elicitation/create` request through its bridge (#1854). Adds the nested + * `elicitation` setting to the UI extension's advertisement, which is half of + * the negotiation a server checks before attaching an App to an elicitation. + * + * Deliberately an input rather than a registry default: the shared + * `InspectorClient` knowing the MCP Apps MIME type says nothing about whether + * the *client* has a sandbox renderer, so CLI and TUI must never advertise it. + * Ignored when the UI extension itself is not advertised — a nested setting on + * an extension we did not declare would be meaningless. + */ + appElicitation?: boolean; } /** @@ -126,6 +139,14 @@ export function buildClientExtensions( : {}; } } + // Nested app-rendered-elicitation opt-in (#1854), layered onto the UI + // extension's own advertisement rather than added as a second extension. + // Guarded on the UI entry actually being present so turning the Apps + // extension off in Server Settings also turns this off. + const uiAdvertisement = map[UI_EXTENSION_KEY]; + if (input.appElicitation && uiAdvertisement) { + map[UI_EXTENSION_KEY] = { ...uiAdvertisement, elicitation: {} }; + } if (input.enterpriseManaged) { map[EMA_EXTENSION_KEY] = {}; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 369a509d..b2b6ae88 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -132,6 +132,13 @@ import { type ModernDetailedTask, } from "./modernTaskSchemas.js"; import { buildClientExtensions } from "./extensions.js"; +import { + getElicitationUiResourceUri, + isFormElicitation, + supportsAppElicitation, + validateAppElicitResult, + type AppElicitationRenderer, +} from "./appElicitation.js"; import { EmptyResultSchema, CallToolResultSchema, @@ -562,6 +569,27 @@ export class InspectorClient extends InspectorClientEventTarget { // Per-extension advertise overrides (#1738); undefined key falls back to the // registry default in ADVERTISABLE_EXTENSIONS. private readonly advertisedExtensions?: Record; + /** + * Host-supplied renderer for app-rendered form elicitations (#1854), or + * undefined on a client that cannot host MCP Apps. Its presence is what + * advertises the nested MCP Apps `elicitation` capability, so this is the one + * fact both the advertisement and the routing gate read. + */ + private readonly appElicitationRenderer?: AppElicitationRenderer; + + /** + * Monotonic counter behind the request-scoped id handed to the renderer. The + * association MUST be per request (a map keyed by this id owns the resource + * URI, renderer instance and promise on the host side) so two concurrent + * elicitations cannot resolve through each other's bridges. + */ + private appElicitationSeq = 0; + /** + * Abort controllers for app-rendered elicitations still awaiting an answer. + * Aborted alongside the native pending queue on disconnect, so a rendered app + * cannot outlive the connection that asked for it. + */ + private activeAppElicitations = new Set(); private receiverTaskTtlMs: number | (() => number); private receiverTaskRecords: Map = new Map(); // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") @@ -616,6 +644,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.elicit = options.elicit ?? true; this.receiverTasks = options.receiverTasks ?? false; this.advertisedExtensions = options.advertisedExtensions; + this.appElicitationRenderer = options.appElicitation; this.receiverTaskTtlMs = options.receiverTaskTtlMs ?? 60_000; this.progress = options.progress ?? true; this.resetTimeoutOnProgress = options.resetTimeoutOnProgress ?? true; @@ -785,6 +814,14 @@ export class InspectorClient extends InspectorClientEventTarget { const advertisedExtensions = buildClientExtensions({ enterpriseManaged: options.oauth?.enterpriseManaged ?? false, advertised: this.advertisedExtensions, + // Read off the built `capabilities.elicitation.form` rather than + // re-deriving from `options.elicit`: the nested MCP Apps `elicitation` + // setting must never be advertised without the core form capability it + // extends, and two derivations of the same fact can drift. Disabling form + // elicitation therefore drops both, as the contract requires. (#1854) + appElicitation: + this.appElicitationRenderer !== undefined && + capabilities.elicitation?.form !== undefined, }); if (Object.keys(advertisedExtensions).length > 0) { capabilities.extensions = { @@ -1688,6 +1725,13 @@ export class InspectorClient extends InspectorClientEventTarget { elicitation.cancel(); } this.pendingElicitations = []; + // App-rendered elicitations (#1854) are not in the queue above — they live + // in the host's renderer — so abort them here on the same teardown paths. + // `tryAppElicitation` removes each controller in its own `finally`. + for (const controller of this.activeAppElicitations) { + controller.abort(); + } + this.activeAppElicitations.clear(); } /** @@ -2820,11 +2864,17 @@ export class InspectorClient extends InspectorClientEventTarget { * corresponding `ElicitResult` (echoed to the server on retry); only a * genuine failure or a `signal` abort rejects. */ - private enqueuePendingElicitation( + private async enqueuePendingElicitation( request: ElicitRequest, origin: PendingRequestOrigin, signal?: AbortSignal, ): Promise { + // App-rendered form elicitation (#1854) is offered first and falls back to + // the native queue below on every failure. Both entry points — the inbound + // `elicitation/create` handler and the MRTR driver's embedded requests — + // funnel through here, so neither can miss the routing. + const appResult = await this.tryAppElicitation(request, signal); + if (appResult) return appResult; // See {@link enqueuePendingSample} — Promise settle is idempotent. return new Promise((resolvePromise, rejectPromise) => { const elicitation = new ElicitationCreateMessage( @@ -2842,6 +2892,92 @@ export class InspectorClient extends InspectorClientEventTarget { }); } + /** + * Attempt to resolve an `elicitation/create` request by rendering the MCP App + * the server attached to it (#1854), returning the app's standard + * `ElicitResult`. + * + * Returns `null` for "not app-rendered — use the native UI", which covers + * every negotiation gate and every failure mode the contract lists: either + * peer did not negotiate the capability, the metadata is absent or unusable, + * the mode is not `form`, the renderer failed (resource read, sandbox/bridge + * init, missing app capability, timeout), or the app returned something that + * is not a valid result for this request. An explicit `decline` or `cancel` + * is a *completed* elicitation and is returned, not fallen back on. + * + * The one case that does not fall back is an abort of the originating + * request: the caller is cancelling the whole elicitation, so re-opening it + * in the native queue would resurrect work the user just abandoned. + */ + private async tryAppElicitation( + request: ElicitRequest, + signal?: AbortSignal, + ): Promise { + const renderer = this.appElicitationRenderer; + if (!renderer) return null; + // Gate 1-3 (client) + 4 (server), from the negotiated capabilities of this + // connection — `this.capabilities` is populated at initialize. + if (!supportsAppElicitation(this.clientCapabilities, this.capabilities)) { + return null; + } + // Only form mode is app-renderable; `url` keeps its existing path. + if (!isFormElicitation(request.params)) return null; + let resourceUri: string | undefined; + try { + resourceUri = getElicitationUiResourceUri(request.params); + } catch (error) { + this.logger.warn( + { error }, + "Elicitation carried unusable _meta.ui.resourceUri; using the native elicitation UI", + ); + return null; + } + if (!resourceUri) return null; + + // Request-scoped abort: forwards the caller's signal (MRTR cancellation) + // and is aborted by `settleAndDropPendingPeerRequests` on disconnect, so a + // rendered app cannot outlive the connection that asked for it. + const controller = new AbortController(); + const forwardAbort = () => controller.abort(signal?.reason); + if (signal) { + if (signal.aborted) forwardAbort(); + else signal.addEventListener("abort", forwardAbort, { once: true }); + } + this.activeAppElicitations.add(controller); + try { + const result = await renderer({ + requestId: `app-elicitation-${++this.appElicitationSeq}`, + resourceUri, + params: request.params, + signal: controller.signal, + }); + this.outputValidator ??= new AjvJsonSchemaValidator(); + const invalid = validateAppElicitResult( + this.outputValidator, + request.params, + result, + ); + if (invalid) { + this.logger.warn( + { resourceUri, reason: invalid }, + "App-rendered elicitation returned an invalid result; using the native elicitation UI", + ); + return null; + } + return result; + } catch (error) { + if (controller.signal.aborted) throw createPendingAbortError(); + this.logger.warn( + { error, resourceUri }, + "App-rendered elicitation failed; using the native elicitation UI", + ); + return null; + } finally { + this.activeAppElicitations.delete(controller); + signal?.removeEventListener("abort", forwardAbort); + } + } + /** * Reject a still-pending request when `signal` aborts (e.g. the user cancels * the tool call while its MRTR round is awaiting an answer). No-op when diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 3b92eb2c..79475bc4 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -23,6 +23,7 @@ import type { Client } from "@modelcontextprotocol/client"; import type { OAuthClientProvider } from "@modelcontextprotocol/client"; import type { Transport } from "@modelcontextprotocol/client"; import type { InspectorLogger } from "../logging/logger.js"; +import type { AppElicitationRenderer } from "./appElicitation.js"; import type { JsonValue } from "../json/jsonUtils.js"; import type { ClientConfig, @@ -1025,6 +1026,21 @@ export interface InspectorClientOptions { */ advertisedExtensions?: Record; + /** + * Renders an app-rendered form elicitation (#1854) and resolves with the + * app's standard `ElicitResult`. + * + * Supplying this is what opts a client into advertising the nested MCP Apps + * `elicitation` capability — so only a client that can actually host an MCP + * App and drive its bridge should pass one (today: the web client, when the + * sandbox renderer is available). CLI and TUI pass nothing and therefore + * never claim the capability, even though they share this client. + * + * A rejection means "fall back to the native elicitation UI"; a resolved + * `decline`/`cancel` is a completed elicitation and is returned to the server. + */ + appElicitation?: AppElicitationRenderer; + /** * Whether to enable listChanged notification handlers (default: true) * If enabled, InspectorClient will subscribe to list_changed notifications and fire diff --git a/package.json b/package.json index 11ddd478..6d1ee7f8 100644 --- a/package.json +++ b/package.json @@ -65,12 +65,13 @@ "coverage:tui": "cd clients/tui && npm run test:coverage", "coverage:web": "cd clients/web && npm run test:coverage", "coverage:launcher": "cd clients/launcher && npm run test:coverage", - "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", + "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", "smoke:cli": "node scripts/smoke-cli.mjs", "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", "smoke:web:browser": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-browser.mjs", "smoke:web:app": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-app.mjs", + "smoke:web:elicit": "cd clients/web && npx playwright install chromium && node ../../scripts/smoke-web-elicitation.mjs", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/pack-and-verify.mjs", "prepack": "npm run build", diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs new file mode 100644 index 00000000..16cf2bd9 --- /dev/null +++ b/scripts/smoke-web-elicitation.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +/** + * Headless-browser smoke for app-rendered form elicitations (#1854). + * + * `smoke:web:app` proves an App *tool* renders. This proves the other thing an + * MCP App can now do: answer a server's `elicitation/create`. It drives the + * whole negotiated chain end to end against the public fixture — + * **connect → call the tool → server elicits → app renders in the sandbox → + * user clicks → the app's standard `ElicitResult` reaches the server** — and + * then drives the SAME tool against a server that did NOT advertise the nested + * MCP Apps `elicitation` capability, which must fall back to the Inspector's + * native elicitation form. + * + * The fallback half is the more valuable of the two. The failure mode this + * feature can produce is not "the app doesn't render" (loud, obvious) but + * "an app renders when it should not have been offered one" — a client that + * over-claims the capability strands every user of a server that never opted + * in. Asserting the native form appears is what pins that. + * + * Two nested frames matter here: the outer trusted sandbox-proxy iframe and the + * inner sandboxed iframe holding the untrusted app. Clicking the app's button + * therefore needs `frameLocator(...).frameLocator(...)`, not one hop. + * + * Set `SMOKE_SCREENSHOT_DIR` to capture PNGs of each state (used to attach + * proof to a PR); unset, it asserts only. Playwright is resolved with a + * `createRequire` based at clients/web/package.json for the reason documented at + * length in smoke-web-browser.mjs. + * + * Expects `clients/web/dist` and `clients/launcher/build` to be built first. + * `test-servers/build` is built on demand if missing, as in smoke:web:app. + */ + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { setTimeout as delay } from "node:timers/promises"; +import { join, resolve } from "node:path"; +import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { stopChild } from "./lib/child-cleanup.mjs"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const requireFromWeb = createRequire( + resolve(repoRoot, "clients/web/package.json"), +); + +const composableServer = join( + repoRoot, + "test-servers", + "build", + "server-composable.js", +); +const configPath = (name) => + join(repoRoot, "test-servers", "configs", `${name}.json`); + +const HOST = "127.0.0.1"; +// Distinct from the other web smokes (6299 / 6298 / 6297) so a prior run whose +// port is still in TIME_WAIT can't EADDRINUSE this one. +const PORT = process.env.SMOKE_WEB_ELICIT_PORT ?? "6296"; +const TOKEN = "smoke-web-elicit-token"; +const TOOL = "app_choose_option"; +const SHOT_DIR = process.env.SMOKE_SCREENSHOT_DIR; + +const servers = []; +let browser = null; +const web = startProdWebServer({ + host: HOST, + port: PORT, + token: TOKEN, + label: "smoke:web:elicit", +}); + +async function shutdown() { + if (browser) { + try { + await browser.close(); + } catch { + // best-effort + } + browser = null; + } + await web.stop(); + while (servers.length) { + await stopChild(servers.pop(), { + label: "smoke:web:elicit", + what: "MCP test server", + }); + } +} + +async function fail(message) { + console.error(`smoke:web:elicit FAILED — ${message}`); + await shutdown(); + process.exit(1); +} + +/** Build the composable test server bundle if it isn't present yet. */ +function ensureTestServer() { + if (existsSync(composableServer)) return; + console.log( + "smoke:web:elicit — building test-servers (missing build output)...", + ); + const r = spawnSync( + process.execPath, + [ + resolveNodeBin("typescript", "tsc", repoRoot), + "-p", + "test-servers", + "--noCheck", + ], + { cwd: repoRoot, stdio: "inherit" }, + ); + if (r.status !== 0 || !existsSync(composableServer)) { + throw new Error( + "could not build the test servers (test-servers/build/server-composable.js). " + + "Run `npm run test-servers:build` from clients/web.", + ); + } +} + +/** + * Spawn a composable test server and wait for the URL it announces. + * + * The announced line is authoritative: `createTestServerHttp` resolves its port + * with `findAvailablePort()`, which walks upward when the configured one is + * taken. Both stdio channels are scanned because the announcement goes to + * stderr (`console.error` in server-composable.ts). + */ +async function startMcpServer(configName) { + const child = spawn( + process.execPath, + [composableServer, "--config", configPath(configName)], + { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"] }, + ); + servers.push(child); + let out = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (out += d)); + let exited = false; + let spawnError = null; + child.on("error", (err) => (spawnError = err)); + child.on("exit", () => (exited = true)); + child.on("close", () => (exited = true)); + + for (let attempt = 0; attempt < 120; attempt++) { + const announced = out.match(/listening at (http:\/\/\S+)/i); + if (announced) return announced[1]; + if (spawnError) { + throw new Error( + `could not spawn the MCP test server (${composableServer}): ${spawnError.message}`, + ); + } + if (exited) throw new Error(`MCP test server exited early:\n${out}`); + await delay(250); + } + throw new Error(`MCP test server did not start within 30s:\n${out}`); +} + +async function loadChromium() { + let chromium; + try { + ({ chromium } = requireFromWeb("playwright")); + } catch (err) { + throw new Error( + `could not resolve the Playwright package from clients/web — run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, + ); + } + try { + return await chromium.launch({ headless: true }); + } catch (err) { + throw new Error( + `chromium failed to launch — on a bare Linux box run \`npx playwright install --with-deps chromium\` for the system libraries (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function shot(page, name) { + if (!SHOT_DIR) return; + mkdirSync(SHOT_DIR, { recursive: true }); + await page.screenshot({ + path: join(SHOT_DIR, `${name}.png`), + fullPage: false, + }); + console.log(`smoke:web:elicit — captured ${name}.png`); +} + +/** Connect to `mcpUrl` through the deep link and wait for the Tools list. */ +async function connect(page, mcpUrl) { + const url = + `${web.baseUrl}/?serverUrl=${encodeURIComponent(mcpUrl)}` + + `&transport=http&autoConnect=${TOKEN}`; + const response = await page.goto(url, { + waitUntil: "domcontentloaded", + timeout: 30_000, + }); + if (!response || !response.ok()) { + throw new Error( + `GET / returned HTTP ${response ? response.status() : "no response"}`, + ); + } + const status = page.locator('[data-testid="connection-status"]'); + await status.waitFor({ state: "attached", timeout: 30_000 }); + const deeplink = await status.getAttribute("data-deeplink"); + if (deeplink !== "parsed") { + throw new Error( + `deep link was not accepted (data-deeplink="${deeplink}") — expected "parsed"`, + ); + } + await page + .locator('[data-testid="connection-status"][data-status="connected"]') + .waitFor({ state: "attached", timeout: 45_000 }); +} + +/** Select the elicitation tool in the Tools tab and run it. */ +async function runTool(page) { + // The main-view tabs are a Mantine SegmentedControl: a visually-hidden radio + // plus a sibling