From ef4259a5444ab31cce47d67fca92f6e0924d193f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 20:43:55 -0400 Subject: [PATCH 001/213] test(smoke): run the headless web smokes in Firefox and WebKit, not just Chromium (#2086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every browser-driven check in this repo launched Chromium and nothing else. That is fine for most of the web client, whose behavior is React and Mantine. It is not fine for the MCP Apps sandbox, which is built out of the primitives that genuinely diverge between engines: a CSP injected as the first child of a srcdoc document, a nested sandboxed iframe, a Permissions-Policy allow attribute, and postMessage origin discipline across those two frames. Nothing else covers that surface — sandbox-csp.test.ts asserts which policy STRING is built (environment-independent by construction), and no Storybook story reaches the sandbox at all, since all three App stories use a data: placeholder iframe and a mock bridge. The engine is now a parameter: SMOKE_BROWSER=webkit npm run smoke:web:app SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes Unset means chromium, so `npm run ci` and `npm run smoke` are unchanged. An unrecognized value is an error rather than a fallback — falling back would report a green Chromium run under a job labelled "webkit", claiming coverage that never ran — and a launch failure names the engine that failed and its own `playwright install` invocation. Selection, launching, and the fatal-vs-benign page-diagnostics split now live in one shared scripts/lib/headless-browser.mjs. The three smokes had each hand-rolled the diagnostics split and two had their own copy of loadChromium; pack:verify goes through the same helper, pinned to Chromium explicitly since it is a packaging check. The npm scripts drop their `cd clients/web && npx playwright install chromium` prefix for scripts/install-smoke-browser.mjs. The engine is a variable now, and a ${VAR:-default} expansion does not expand under Windows' cmd.exe; npx is also a .cmd shim there that a shell-free spawn cannot start, so this resolves the Playwright CLI through resolveNodeBin — the #1939 problem solved the #1939 way. CI gains a `Sandbox smokes ()` matrix job rather than matrixing `build`, which also runs validate, the coverage gate, two verify gates and Storybook — all engine-independent. Both publish jobs now need it. WebKit is supported by the tooling but deliberately NOT in the CI matrix yet: pointing the smokes at it immediately found #2132, a pre-existing Safari incompatibility in the web client's SSE transport that strands the last message on /api/mcp/events, so an MCP App never leaves "loading". That is a product bug affecting real Safari users, with its own diagnosis and a verified fix, and it is not a matrix change. Adding `webkit` here is a one-word diff once it lands. An engine is either green in CI or absent from it; a continue-on-error job would report coverage nobody is held to. Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 + .github/workflows/main.yml | 92 ++++++++++++++-- AGENTS.md | 26 ++++- README.md | 21 +++- package.json | 9 +- scripts/install-smoke-browser.mjs | 84 +++++++++++++++ scripts/lib/headless-browser.mjs | 146 +++++++++++++++++++++++++ scripts/lib/headless-browser.test.mjs | 148 ++++++++++++++++++++++++++ scripts/lib/mcp-app-flow.mjs | 73 +------------ scripts/pack-and-verify.mjs | 9 +- scripts/smoke-web-app.mjs | 35 +++++- scripts/smoke-web-browser.mjs | 127 +++++++++------------- scripts/smoke-web-elicitation.mjs | 102 ++++++++---------- 13 files changed, 652 insertions(+), 222 deletions(-) create mode 100644 scripts/install-smoke-browser.mjs create mode 100644 scripts/lib/headless-browser.mjs create mode 100644 scripts/lib/headless-browser.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3529774329..a3a09e87fd 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -105,6 +105,8 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `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`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. 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. +- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix pending #2132, a real Safari SSE hang it found), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. + - **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_. - **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. - **Every PR references an issue**, first body line `Closes #`. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a71918807c..b967625858 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,8 +28,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '22.x' - cache: 'npm' + node-version: "22.x" + cache: "npm" - name: Install dependencies (root + all clients) # The root postinstall (scripts/install-clients.mjs) cascades @@ -115,6 +115,84 @@ jobs: working-directory: ./clients/web run: npm run test:storybook + # The same headless web smokes the `build` job already runs in Chromium, run + # again in the other engines this repo supports (#2086). + # + # Why this is a job of its own rather than a matrix over `build`: `build` also + # runs validate, the coverage gate, two verify gates and Storybook, none of + # which are engine-dependent — matrixing it would triple all of that to gain + # three browser smokes. Chromium is deliberately NOT in the matrix here for + # the same reason: `npm run smoke` inside `build` is exactly the local + # `npm run ci` path, and it already covers it. Supported set = the Chromium run + # there plus whatever the matrix below names. + # + # This is the only place the MCP Apps sandbox is exercised on a non-Chromium + # engine, and it earned its keep immediately: pointing the smokes at WebKit is + # what found #2132, a Safari-only hang in the web client's SSE transport that + # every Chromium-only tier had been green through. + # + # The unit tests cannot substitute — `sandbox-csp.test.ts` asserts + # which policy STRING is built, which passes identically on an engine that + # ignores `` CSP entirely — and no Storybook story reaches the sandbox at + # all (all three App stories use a `data:` placeholder iframe and a mock + # bridge). Note Playwright's WebKit is a WebKit build, not Safari: close enough + # to catch engine-level CSP and iframe divergence, not close enough to certify + # Safari. + browser-engine-smokes: + runs-on: ubuntu-latest + strategy: + # Report every engine's verdict. Failing fast would hide a WebKit-only + # regression behind a Firefox-only one, which is the exact distinction + # this job exists to draw. + fail-fast: false + matrix: + # `webkit` belongs here and is deliberately absent: the smokes RUN in it + # (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the + # two App smokes fail on a real, pre-existing Safari incompatibility in + # the web client's SSE transport — #2132, which carries the diagnosis and + # the verified fix. Adding it here is a one-word diff once that lands. + # An engine is either green or absent; a `continue-on-error` job would + # report coverage nobody is holding to a standard. + browser: [firefox] + name: Sandbox smokes (${{ matrix.browser }}) + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: "22.x" + cache: "npm" + + - name: Install dependencies (root + all clients) + run: npm install + + - name: Build all clients + # The smokes need clients/web/dist and the cli/tui/launcher bundles. + # `build` rather than `validate` — the format/lint/typecheck half is + # engine-independent and already ran in the `build` job. + run: npm run build + + - name: Cache Playwright browsers + uses: actions/cache@v6 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ matrix.browser }}-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} + + - name: Install Playwright ${{ matrix.browser }} + working-directory: ./clients/web + # `--with-deps` for the system libraries a bare runner lacks; WebKit is + # the large download here and dominates this step. + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Run the headless web smokes in ${{ matrix.browser }} + # The set of smokes lives in the `smoke:web:engine` npm script, not + # enumerated here, so adding one covers every engine automatically. + env: + SMOKE_BROWSER: ${{ matrix.browser }} + run: npm run smoke:web:engine + # Publish the single `@modelcontextprotocol/inspector` package to npm on a # published GitHub release. v2 is not an npm workspace, so there is no # `publish-all` / `--workspaces` (v1) — just one `npm publish`, whose `prepack` @@ -127,7 +205,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release - needs: build + needs: [build, browser-engine-smokes] # Serialize publishes so two releases cut in quick succession can't run # overlapping `npm publish`es. Never cancel an in-flight publish. concurrency: @@ -145,9 +223,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '22.x' - cache: 'npm' - registry-url: 'https://registry.npmjs.org' + node-version: "22.x" + cache: "npm" + registry-url: "https://registry.npmjs.org" - name: Assert release tag matches package version # `npm publish` ships whatever `version` is in the root package.json, @@ -220,7 +298,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release - needs: build + needs: [build, browser-engine-smokes] permissions: contents: read packages: write diff --git a/AGENTS.md b/AGENTS.md index 936af5d8cb..fcc7d93565 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,7 +270,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; announced-child.mjs owns the +│ # through — #1965; headless-browser.mjs owns the +│ # browser the web smokes drive — SMOKE_BROWSER +│ # engine selection (deny-by-default), the launch, +│ # and the fatal-vs-benign page-diagnostics split, +│ # shared by all three smokes and pack:verify — +│ # #2086; announced-child.mjs owns the │ # spawn-and-wait-for-a-readiness-line step, publishing │ # the child via `onSpawn` BEFORE the wait so the │ # caller's teardown reaches it on every throw path — @@ -895,14 +900,29 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - `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`, `smoke:web:app` and `smoke:web:elicit`**, so the four 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 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=&openApp=…&appArgs=…&autoOpen=`). 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`. Both mechanics now live in `scripts/lib/announced-child.mjs` rather than in the smoke, so the failure path is testable: this smoke's happy path always receives the announcement, so nothing it could assert would prove that a child alive *through* the 30s timeout is still reachable by `shutdown()` — the case that orphaned a live server (#2000). The helper publishes the child via `onSpawn` before waiting, and `scripts/lib/announced-child.test.mjs` drives real `node -e` children (not spies) to assert it is published, still alive when the throw lands, and actually killable. Same reason `teardownWebServer` was extracted from `prod-web-server.mjs`'s `stop()`. **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, and since #2003 it owns it *properly* — it drives this smoke's first phase against the installed tarball rather than only asserting the proxy page exists. The flow therefore lives in `scripts/lib/mcp-app-flow.mjs` (deep-link construction, the staged assertions, the Chromium/diagnostics plumbing) and is **shared, not copied**: two copies of the deep-link shape would drift, and a drifted link fails as a silent timeout rather than a mismatch. Keep both consumers — `pack:verify` is network-bound and local/release-only, so it does not run in `npm run ci`, where this smoke is the only thing exercising the App path at all, and the **`_meta.ui.domain` phase (#2056) is this smoke's alone**: the dedicated app origin is served by the same runner the packaging checks already cover, so driving it a second time from the tarball would cost a browser launch for no new packaging signal. 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. +- `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 a headless browser (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). That resolution and the launch itself now live in `scripts/lib/headless-browser.mjs`, shared by all three web smokes and `pack:verify`, which is also where `SMOKE_BROWSER` picks the engine (#2086, below). The npm script no longer prefixes `cd clients/web && npx playwright install chromium`: `scripts/install-smoke-browser.mjs` fetches the engine's binary instead, because the engine is now a variable (a `${VAR:-default}` expansion does not expand under Windows' `cmd.exe`, so it would try to install a browser literally named that) and because `npx` is a `.cmd` shim there that a shell-free spawn cannot start — the #1939 problem, solved the #1939 way, via `resolveNodeBin`. +- `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=&openApp=…&appArgs=…&autoOpen=`). 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`. Both mechanics now live in `scripts/lib/announced-child.mjs` rather than in the smoke, so the failure path is testable: this smoke's happy path always receives the announcement, so nothing it could assert would prove that a child alive *through* the 30s timeout is still reachable by `shutdown()` — the case that orphaned a live server (#2000). The helper publishes the child via `onSpawn` before waiting, and `scripts/lib/announced-child.test.mjs` drives real `node -e` children (not spies) to assert it is published, still alive when the throw lands, and actually killable. Same reason `teardownWebServer` was extracted from `prod-web-server.mjs`'s `stop()`. **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, and since #2003 it owns it *properly* — it drives this smoke's first phase against the installed tarball rather than only asserting the proxy page exists. The flow therefore lives in `scripts/lib/mcp-app-flow.mjs` (deep-link construction and the staged assertions; the browser launch and the page-diagnostics split live one level down, in `scripts/lib/headless-browser.mjs`) and is **shared, not copied**: two copies of the deep-link shape would drift, and a drifted link fails as a silent timeout rather than a mismatch. Keep both consumers — `pack:verify` is network-bound and local/release-only, so it does not run in `npm run ci`, where this smoke is the only thing exercising the App path at all, and the **`_meta.ui.domain` phase (#2056) is this smoke's alone**: the dedicated app origin is served by the same runner the packaging checks already cover, so driving it a second time from the tarball would cost a browser launch for no new packaging signal. 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. - `smoke:web:elicit` (`scripts/smoke-web-elicitation.mjs`, #1854) is the app-rendered **elicitation** counterpart of `smoke:web:app`: same prod `--web` server and the same deep-link connect, but it then calls `app_choose_option` from the Tools tab, waits for `[data-testid="app-elicitation"][data-app-elicitation-status="ready"]`, clicks a choice **inside the sandboxed app** (two `frameLocator` hops — the trusted sandbox proxy, then the untrusted app), and asserts the app's standard `ElicitResult` comes back in the *tool result*, i.e. that it reached the server rather than merely the host. It then repeats against `app-elicitation-native-http.json` — the same tool and app on a server that never advertised the nested MCP Apps `elicitation` capability — and asserts the **native** elicitation dialog takes it and no app modal is rendered. That second half is the more valuable one: the failure this feature can produce is not "the app doesn't render" but "an app renders when it should not have been offered one", which strands every user of a server that never opted in. Set `SMOKE_SCREENSHOT_DIR` to capture PNGs of the three states (used for PR proof); unset, it asserts only. Two mechanics worth knowing: the main-view tabs are a Mantine `SegmentedControl`, so there is no `role="tab"` — the clickable element is the sibling `label[for$="-Tools"]`; and the prompt string also appears in the (hidden) Protocol-tab payload, so the fallback assertion is scoped to the dialog rather than a bare text lookup. - **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 ` 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. +### The web smokes run in three browser engines (#2086) + +**`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** CI is green on Chromium and Firefox; **WebKit runs but is not in the CI matrix yet** — see the last bullet. `npm run smoke:web:engine` runs all three smokes in whichever engine is selected — that script, not the workflow YAML, is the list of engine-covered smokes, so adding a fourth covers every engine without touching CI. + +Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** — `resolveBrowserName`, `loadBrowser`, and the `attachPageDiagnostics` / `FATAL_CONSOLE` split the smokes had each hand-rolled. Reach for it rather than launching Playwright in a new script. + +- **The engine matters for one surface, and it is the MCP Apps sandbox.** Most of the web client is React and Mantine, where a second engine buys little. The sandbox is built out of the primitives that genuinely diverge: a CSP `` injected as the first `` child of a **`srcdoc`** document, a **nested** sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. +- **No other tier can substitute, so don't propose one.** `sandbox-csp.test.ts` asserts which policy _string_ is built — environment-independent by construction, and it would pass identically on an engine that ignores `` CSP entirely. And **no Storybook story reaches the sandbox at all**: all three App stories (`AppRenderer`, `AppsScreen`, `AppElicitationHost`) point the iframe at a `data:` placeholder and hand the renderer a mock bridge, so `sandbox-csp.ts` is imported by exactly two things in the tree — its own test and `createAppBridgeFactory.ts`. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. +- **An unrecognized `SMOKE_BROWSER` is an error, never a fallback.** Falling back to Chromium would report a green Chromium run under a job labelled `webkit` — coverage claimed but not run, which is worse than none. +- **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. +- **CI runs Chromium in the `build` job's `npm run smoke` and the rest in a `Sandbox smokes ()` matrix job** with `fail-fast: false`, so a WebKit-only regression is not hidden behind a Firefox-only one. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. +- **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. +- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132) carries the diagnosis and a verified fix. Adding `webkit` to the matrix is a one-word diff once that lands; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. Worth noting the matrix found this on its first outing, against a bug every Chromium-only tier had been green through — and it is a _product_ bug, not a test one. +- ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. + ### 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. diff --git a/README.md b/README.md index 1272b2b0d5..6e5280ce80 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,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 three 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, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one 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 smoke` | End-to-end smokes through the built launcher (`--help` dispatch + prod cli/tui/web), plus three headless-browser 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, and an **app-rendered elicitation** smoke (`smoke:web:elicit`) that drives one 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. All three take their engine from `SMOKE_BROWSER` — see [Supported browsers](#supported-browsers). | | `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:bundle-externals` | Guards the must-not-bundle invariant (#2067): for each tsup-bundled client it reads the **built** `build/` output and fails if any package that must stay external was inlined anyway. Candidates are the union of the client's own `external` array **and the root manifest's `dependencies`** — the latter because #2067 was a *missing* `external` entry, which a self-referential check would have passed. Detection is via esbuild's `// ` module banners, so it covers both shapes — a separate `-HASH.js` chunk (what a dynamically `import()`ed CommonJS package produces) and a statically-imported package folded straight into `index.js`, which emits no chunk at all. A build with no banners fails as such rather than passing clean, so enabling `minify` cannot silently retire the check. `undici` was declared only in the root and `clients/cli` manifests, and tsup auto-externalizes only what the _nearest_ manifest declares, so the web and TUI bundles inlined 1.05MB of it — and CommonJS inlined into an ESM bundle throws `Dynamic require of "assert" is not supported` on first use, from a specifier no user-side install can satisfy. Reads the output rather than the config because those two disagreed for four releases. 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`. | @@ -470,6 +470,25 @@ Per-client scripts exist too (`validate:web`, `coverage:cli`, `smoke:tui`, …), 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). +### Supported browsers + +The three headless web smokes — `smoke:web:browser`, `smoke:web:app`, `smoke:web:elicit` — take their browser engine from `SMOKE_BROWSER`, which accepts **`chromium`, `firefox` and `webkit`** ([#2086](https://github.com/modelcontextprotocol/inspector/issues/2086)): + +```bash +SMOKE_BROWSER=webkit npm run smoke:web:app # one smoke, one engine +SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine +``` + +Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke` and the remaining engines in a separate `Sandbox smokes ()` matrix job, rather than running the whole `build` job once per engine (validate, the coverage gate, the two verify gates and Storybook are all engine-independent). An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. + +**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport, tracked with its diagnosis and a verified fix in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132). That is the matrix doing its job on its first outing: the bug affects the shipped web UI in Safari, and every Chromium-only tier had been green through it. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. + +**Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. + +> ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** It is close enough to catch engine-level CSP and iframe divergence, and not close enough to certify Safari specifically. A green run here is not a Safari guarantee. + +`pack:verify` stays Chromium-only on purpose — it is a _packaging_ check, and the engine question belongs where the sandbox is under test. + ## Publishing The root `@modelcontextprotocol/inspector` package ships as **one tarball with a single version number** — no separate `-web` / `-cli` / `-tui` / `-core` packages. `npm run build` builds every client, then `prepack` runs before `npm publish`. Runtime dependencies are declared on the root `package.json`; client builds bundle `@inspector/core` and externalize npm packages resolved from the root install. diff --git a/package.json b/package.json index 1eddb213c1..9337b5a1ed 100644 --- a/package.json +++ b/package.json @@ -70,11 +70,12 @@ "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:web:browser": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-browser.mjs", + "smoke:web:app": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-app.mjs", + "smoke:web:elicit": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-elicitation.mjs", + "smoke:web:engine": "npm run smoke:web:browser && npm run smoke:web:app && npm run smoke:web:elicit", "smoke:launcher": "node scripts/smoke-launcher.mjs", - "pack:verify": "cd clients/web && npx playwright install chromium && cd ../.. && node scripts/pack-and-verify.mjs", + "pack:verify": "node scripts/install-smoke-browser.mjs chromium && node scripts/pack-and-verify.mjs", "prepack": "npm run build", "postinstall": "node scripts/install-clients.mjs" }, diff --git a/scripts/install-smoke-browser.mjs b/scripts/install-smoke-browser.mjs new file mode 100644 index 0000000000..d052528c44 --- /dev/null +++ b/scripts/install-smoke-browser.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Fetch the Playwright browser binary a headless web smoke is about to launch + * (#2086). + * + * This replaces the `cd clients/web && npx playwright install chromium` prefix + * each smoke's npm script used to carry. Two reasons it is a script rather than + * a longer npm-script line: + * + * - The engine is now a variable. `npx playwright install ${SMOKE_BROWSER:-…}` + * is a POSIX-shell expansion that does not expand under Windows' `cmd.exe`, + * which npm uses there — it would silently try to install a browser named + * `${SMOKE_BROWSER:-chromium}`. + * - `npx` is a `.cmd` shim on Windows, which a shell-free spawn cannot start. + * `resolveNodeBin` walks to the JS entry behind the package's `bin` and runs + * it with `process.execPath` instead — the same fix #1939 made for the + * verify scripts, and it keeps the resolution pinned to the repo's own + * Playwright rather than whatever `npx` might fetch. + * + * Usage: `node scripts/install-smoke-browser.mjs [engine]`. With no argument the + * engine comes from `SMOKE_BROWSER` (default `chromium`); pass one explicitly + * where the consumer's engine is fixed regardless of that variable, as + * `pack:verify` does. + */ + +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { + SUPPORTED_BROWSERS, + resolveBrowserName, +} from "./lib/headless-browser.mjs"; +import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const webDir = join(repoRoot, "clients", "web"); + +const requested = process.argv[2]; +let browserName; +try { + if (requested === undefined) { + browserName = resolveBrowserName(); + } else if (SUPPORTED_BROWSERS.includes(requested)) { + browserName = requested; + } else { + throw new Error( + `unsupported browser "${requested}" — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, + ); + } +} catch (err) { + console.error( + `install-smoke-browser: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); +} + +let cli; +try { + cli = resolveNodeBin("playwright", "playwright", webDir); +} catch (err) { + console.error( + "install-smoke-browser: could not resolve the Playwright CLI from clients/web — " + + `run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, + ); + process.exit(1); +} + +// `install` is a no-op when the binary is already present, so this is cheap on +// a warm machine and on a CI runner whose Playwright cache was restored. +const result = spawnSync(process.execPath, [cli, "install", browserName], { + cwd: webDir, + stdio: "inherit", +}); +if (result.error) { + console.error( + `install-smoke-browser: could not run the Playwright CLI: ${result.error.message}`, + ); + process.exit(1); +} +if (result.status !== 0) { + console.error( + `install-smoke-browser: \`playwright install ${browserName}\` exited ${result.status}`, + ); + process.exit(result.status ?? 1); +} diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs new file mode 100644 index 0000000000..f20ec266e5 --- /dev/null +++ b/scripts/lib/headless-browser.mjs @@ -0,0 +1,146 @@ +/** + * The headless browser the web smokes drive: which engine, how it is launched, + * and the two error channels a page reports on (#2086). + * + * All three smokes (`smoke:web:browser`, `smoke:web:app`, `smoke:web:elicit`) + * and `pack:verify` go through here, so the engine is a parameter in exactly + * one place and the diagnostics split is stated once rather than hand-rolled + * four times. + * + * Every browser-driven check in this repo used to be Chromium-only. That is + * fine for most of the web client, whose behavior is React and Mantine — and it + * is NOT fine for the MCP Apps sandbox, which is built out of exactly the + * primitives that diverge between engines: a CSP `` injected into a + * `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` + * attribute, and `postMessage` origin discipline across those two frames. A + * regression in any of those is invisible unless it also reproduces in Chromium. + * + * The unit tests cannot cover it either: `sandbox-csp.test.ts` asserts which + * policy STRING is built, which would pass identically on an engine that + * ignores `` CSP entirely. And no Storybook story reaches the sandbox at + * all — all three App stories point the iframe at a `data:` placeholder and hand + * the renderer a mock bridge. So the smokes are the only place the sandbox is + * genuinely exercised, and this module is what lets them be pointed at another + * engine. + * + * ⚠️ Playwright's WebKit is a WebKit build, not Safari. It is close enough to + * catch engine-level CSP and iframe divergence, and not close enough to certify + * Safari specifically — a green run here is not a Safari guarantee. + * + * 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 a `cd clients/web` + * in the npm script would NOT make it resolvable. See smoke-web-browser.mjs's + * header for the full gotcha. + */ + +import { createRequire } from "node:module"; +import { join } from "node:path"; + +/** + * 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. smoke-web-browser.mjs's header documents the reasoning at length. + */ +export const FATAL_CONSOLE = + /^Uncaught\b|Failed to fetch dynamically imported module/; + +/** + * Attach the two error channels a headless page reports on. + * + * An uncaught *synchronous* page error fires `pageerror`; its async twin — an + * unhandled rejection or a failed dynamic import — is not a `pageerror` at all + * and arrives on the console channel instead. Both are captured, and both are + * read on every engine rather than branching per browser: which channel an + * engine picks is exactly the kind of detail that differs between them, and + * reading both makes that difference irrelevant. Only `fatal()` is a failure, so + * ordinary console noise stays a diagnostic. + */ +export function attachPageDiagnostics(page) { + 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()); + }); + return { + pageErrors, + consoleErrors, + fatalConsole: () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m)), + benignConsole: () => consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)), + fatal: () => [ + ...pageErrors, + ...consoleErrors.filter((m) => FATAL_CONSOLE.test(m)), + ], + }; +} + +/** The supported engine set. Anything else is a typo, not a request. */ +export const SUPPORTED_BROWSERS = ["chromium", "firefox", "webkit"]; + +/** The engine used when nothing asks for another one. */ +export const DEFAULT_BROWSER = "chromium"; + +/** The env var each smoke reads to pick its engine. */ +export const BROWSER_ENV_VAR = "SMOKE_BROWSER"; + +/** + * Resolve the engine name from the environment, deny-by-default. + * + * An unrecognized value FAILS rather than falling back to Chromium: a silent + * fallback would report a green Chromium run under a job labelled "webkit", + * which is worse than no coverage — it claims coverage that never ran. + */ +export function resolveBrowserName(env = process.env) { + const raw = env[BROWSER_ENV_VAR]; + if (raw === undefined || raw.trim() === "") return DEFAULT_BROWSER; + const name = raw.trim().toLowerCase(); + if (!SUPPORTED_BROWSERS.includes(name)) { + throw new Error( + `${BROWSER_ENV_VAR}="${raw}" is not a supported browser — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, + ); + } + return name; +} + +/** + * Launch a headless browser of `browserName`, resolved from the web client's + * install. + * + * The launch-failure message names the engine that failed and the + * `playwright install` invocation that fixes it — naming "chromium" while + * WebKit was the thing missing sends the reader off to install a browser they + * already have. + */ +export async function loadBrowser(repoRoot, browserName = DEFAULT_BROWSER) { + if (!SUPPORTED_BROWSERS.includes(browserName)) { + throw new Error( + `unsupported browser "${browserName}" — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, + ); + } + const requireFromWeb = createRequire( + join(repoRoot, "clients", "web", "package.json"), + ); + let playwright; + try { + playwright = 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 playwright[browserName].launch({ headless: true }); + } catch (err) { + throw new Error( + `${browserName} failed to launch — run \`npx playwright install --with-deps ${browserName}\`, ` + + `which fetches the browser and (on a bare Linux box) its system libraries ` + + `(${err instanceof Error ? err.message : String(err)})`, + ); + } +} diff --git a/scripts/lib/headless-browser.test.mjs b/scripts/lib/headless-browser.test.mjs new file mode 100644 index 0000000000..187d02e7d7 --- /dev/null +++ b/scripts/lib/headless-browser.test.mjs @@ -0,0 +1,148 @@ +/** + * Unit tests for the headless-browser engine selection (#2086). + * + * The smokes cannot check this themselves: each one resolves exactly one engine + * per process and then spends its whole run inside the happy path, so the branch + * that matters most — an unrecognized `SMOKE_BROWSER` — is dead code from their + * point of view. It is also the branch whose failure is silent rather than loud: + * a fallback to Chromium there would report a green run under a job labelled + * "webkit", claiming coverage that never ran. + * + * `loadBrowser`'s own launch path is deliberately not covered here — it takes a + * real browser binary, which is what the smokes are for. Only its argument + * validation, which fails before any of that, is. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + BROWSER_ENV_VAR, + DEFAULT_BROWSER, + SUPPORTED_BROWSERS, + attachPageDiagnostics, + loadBrowser, + resolveBrowserName, +} from "./headless-browser.mjs"; + +describe("resolveBrowserName", () => { + it("defaults to chromium when the variable is unset", () => { + assert.equal(resolveBrowserName({}), DEFAULT_BROWSER); + }); + + it("treats an empty or whitespace-only value as unset", () => { + // A CI expression that resolves to nothing (`SMOKE_BROWSER: ${{ … }}` with + // an undefined matrix key) sets the variable to "" rather than removing it. + for (const raw of ["", " "]) { + assert.equal( + resolveBrowserName({ [BROWSER_ENV_VAR]: raw }), + DEFAULT_BROWSER, + ); + } + }); + + it("accepts every supported engine", () => { + for (const name of SUPPORTED_BROWSERS) { + assert.equal(resolveBrowserName({ [BROWSER_ENV_VAR]: name }), name); + } + }); + + it("normalizes surrounding whitespace and case", () => { + assert.equal( + resolveBrowserName({ [BROWSER_ENV_VAR]: " WebKit " }), + "webkit", + ); + }); + + it("rejects an unrecognized engine rather than falling back", () => { + assert.throws( + () => resolveBrowserName({ [BROWSER_ENV_VAR]: "safari" }), + // The message must quote what was asked for AND list what is accepted — + // "safari" is the most likely typo here and its fix is not guessable. + (err) => + /safari/.test(err.message) && + SUPPORTED_BROWSERS.every((name) => err.message.includes(name)), + ); + }); +}); + +describe("loadBrowser", () => { + it("rejects an unsupported engine before touching Playwright", async () => { + await assert.rejects( + // A repo root that does not exist: reaching the `createRequire` would + // throw a different (resolution) error, so this also pins the ORDER — + // validation first, so the message names the real mistake. + () => loadBrowser("/nonexistent-repo-root", "safari"), + /unsupported browser "safari"/, + ); + }); +}); + +describe("attachPageDiagnostics", () => { + /** Minimal Playwright `page` stand-in: records handlers, replays events. */ + function fakePage() { + const handlers = {}; + return { + on: (event, fn) => { + (handlers[event] ??= []).push(fn); + }, + emit: (event, arg) => handlers[event]?.forEach((fn) => fn(arg)), + }; + } + + const consoleMessage = (type, text) => ({ + type: () => type, + text: () => text, + }); + + it("splits fatal console errors from benign noise", () => { + const page = fakePage(); + const diagnostics = attachPageDiagnostics(page); + + page.emit("console", consoleMessage("error", "Uncaught (in promise) boom")); + page.emit( + "console", + consoleMessage("error", "Failed to fetch dynamically imported module x"), + ); + // The two shapes that used to flake CI, and must stay diagnostics. + page.emit( + "console", + consoleMessage("error", "Failed to load resource: net::ERR_FAILED"), + ); + page.emit( + "console", + consoleMessage("error", "Warning: each child needs a key"), + ); + // Non-error console output is ignored entirely. + page.emit("console", consoleMessage("log", "Uncaught looking but a log")); + + assert.deepEqual(diagnostics.fatalConsole(), [ + "Uncaught (in promise) boom", + "Failed to fetch dynamically imported module x", + ]); + assert.deepEqual(diagnostics.benignConsole(), [ + "Failed to load resource: net::ERR_FAILED", + "Warning: each child needs a key", + ]); + }); + + it("counts every pageerror as fatal, whatever it was thrown as", () => { + const page = fakePage(); + const diagnostics = attachPageDiagnostics(page); + + page.emit("pageerror", new Error("sync boom")); + page.emit("pageerror", "thrown as a string"); + page.emit("console", consoleMessage("error", "benign")); + + assert.deepEqual(diagnostics.pageErrors, [ + "sync boom", + "thrown as a string", + ]); + assert.deepEqual(diagnostics.fatal(), ["sync boom", "thrown as a string"]); + }); + + it("reports nothing on a clean page", () => { + const diagnostics = attachPageDiagnostics(fakePage()); + assert.deepEqual(diagnostics.fatal(), []); + assert.deepEqual(diagnostics.benignConsole(), []); + }); +}); diff --git a/scripts/lib/mcp-app-flow.mjs b/scripts/lib/mcp-app-flow.mjs index 23233f613a..99a21e3c9a 100644 --- a/scripts/lib/mcp-app-flow.mjs +++ b/scripts/lib/mcp-app-flow.mjs @@ -22,14 +22,12 @@ * **test server** is always a repo fixture — it is not in the tarball and * should not be. * - * 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 a `cd clients/web` - * in the npm script would NOT make it resolvable. Same gotcha as - * smoke-web-browser.mjs; see its header. + * Launching the browser is NOT this module's job — `lib/headless-browser.mjs` + * owns that, so the engine is a parameter rather than a hard-coded Chromium + * (#2086) and so `smoke:web:browser`, which does not drive an App at all, can + * share it without importing this file. */ -import { createRequire } from "node:module"; import { join } from "node:path"; import { startAnnouncedChild } from "./announced-child.mjs"; import { @@ -40,16 +38,6 @@ import { /** The App tool the `mcp-app-http.json` fixture serves. */ export 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. - */ -export const FATAL_CONSOLE = - /^Uncaught\b|Failed to fetch dynamically imported module/; - /** Path to the composable test server build, relative to the repo root. */ export function composableServerPath(repoRoot) { return testServerEntryPath(repoRoot, "composable"); @@ -143,59 +131,6 @@ export function buildAppDeepLink({ ); } -/** - * Attach the two error channels a headless page reports on. - * - * An uncaught *synchronous* page error fires `pageerror`; its async twin — an - * unhandled rejection or a failed dynamic import — is not a `pageerror` at all, - * Chromium reports it on the console channel instead. Both are captured; only - * `fatal()` is a failure, so ordinary console noise stays a diagnostic. - */ -export function attachPageDiagnostics(page) { - 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()); - }); - return { - pageErrors, - consoleErrors, - fatalConsole: () => consoleErrors.filter((m) => FATAL_CONSOLE.test(m)), - benignConsole: () => consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)), - fatal: () => [ - ...pageErrors, - ...consoleErrors.filter((m) => FATAL_CONSOLE.test(m)), - ], - }; -} - -/** Launch headless Chromium, resolved from the web client's install. */ -export async function loadChromium(repoRoot) { - const requireFromWeb = createRequire( - join(repoRoot, "clients", "web", "package.json"), - ); - 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)})`, - ); - } -} - /** * Drive **connect → open app → widget ready** on an already-open page. * diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 260eeb48b4..1145f4b22b 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -60,12 +60,11 @@ import { ensureTestServers, testServerEntryPath, } from "./lib/ensure-test-servers.mjs"; +import { attachPageDiagnostics, loadBrowser } from "./lib/headless-browser.mjs"; import { APP_TOOL, - attachPageDiagnostics, buildAppDeepLink, driveAppFlow, - loadChromium, startMcpAppServer, } from "./lib/mcp-app-flow.mjs"; import { winShellArgs } from "./lib/win-shell-args.mjs"; @@ -567,7 +566,11 @@ async function verifyAppRender(baseUrl, token, whenWebServerExits) { }, label: LABEL, }); - browser = await loadChromium(repoRoot); + // Chromium explicitly, not `resolveBrowserName()`: this is a *packaging* + // check, and the engine matrix (#2086) belongs to the smokes, where the + // sandbox surface is what is under test. Pinning it also means `pack:verify` + // cannot be pointed at an engine its npm script never installed. + browser = await loadBrowser(repoRoot, "chromium"); const page = await browser.newPage(); const diagnostics = attachPageDiagnostics(page); diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index d4ea5c7d1f..81ba3199ef 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -41,6 +41,16 @@ * renamed* without its reader being updated, a repo-tree failure pack:verify * would only find later. * + * ── Which engine ──────────────────────────────────────────────────────────── + * + * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or + * `webkit`); CI runs all three (#2086). This smoke is one of the two places the + * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the + * primitives that actually diverge between engines — `srcdoc` CSP inheritance, + * nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. See + * `lib/headless-browser.mjs`, including why a green WebKit run is not a Safari + * guarantee. + * * Expects `clients/web/dist` and `clients/launcher/build` to be built first — * the validate / CI ordering guarantees this. `test-servers/build` is rebuilt on * every run, as in smoke:cli — see `scripts/lib/ensure-test-servers.mjs` for why @@ -54,17 +64,34 @@ import { join, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; import { stopChild } from "./lib/child-cleanup.mjs"; import { - APP_TOOL, attachPageDiagnostics, + loadBrowser, + resolveBrowserName, +} from "./lib/headless-browser.mjs"; +import { + APP_TOOL, buildAppDeepLink, driveAppFlow, - loadChromium, sandboxProxyPageFor, startMcpAppServer, } from "./lib/mcp-app-flow.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); -const LABEL = "smoke:web:app"; + +// Resolved before anything is started, so an unsupported SMOKE_BROWSER fails +// immediately rather than after a web server and two MCP servers are up. Every +// message this smoke prints carries the engine, so a matrix failure names which +// one broke without the reader having to match it to a job title. +let BROWSER; +try { + BROWSER = resolveBrowserName(); +} catch (err) { + console.error( + `smoke:web:app FAILED — ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); +} +const LABEL = `smoke:web:app [${BROWSER}]`; // Resolved exactly as the runtime does, from the built runner's directory. const sandboxProxyPage = sandboxProxyPageFor( @@ -141,7 +168,7 @@ try { label: LABEL, }); await server.waitForReady(); - browser = await loadChromium(repoRoot); + browser = await loadBrowser(repoRoot, BROWSER); const page = await browser.newPage(); const diagnostics = attachPageDiagnostics(page); diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index ade88c5477..aa50a5a359 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -29,12 +29,13 @@ * Two channels carry the failure. A *synchronous* uncaught exception (the * CASE-1 shape above — a stub call during module init) fires `pageerror`. Its * *async* twin — the same `TypeError` reached through an `await`/`.then()`, or a - * failed dynamic import (this app lazy-loads chunks) — is NOT a `pageerror`; - * Chromium logs it on the **console** channel as `Uncaught (in promise) …` / - * `Failed to fetch dynamically imported module`. Both are hard failures. + * failed dynamic import (this app lazy-loads chunks) — is NOT a `pageerror`; it + * arrives on the **console** channel as `Uncaught (in promise) …` / + * `Failed to fetch dynamically imported module`. Both are hard failures, and + * both channels are read on every engine rather than branching per browser. * - * Every *other* `console.error` is NOT a hard failure: the console is where - * Chromium also reports benign things a boot smoke shouldn't fail on — a failed + * Every *other* `console.error` is NOT a hard failure: the console is also where + * benign things a boot smoke shouldn't fail on land — a failed * subresource load (e.g. the Google-Fonts `` in index.html on a * network-restricted box) or a React key/prop warning. Those are printed as * diagnostics. The `Uncaught` / dynamic-import prefixes are unambiguous — a @@ -42,29 +43,44 @@ * never starts with `Uncaught` — so hard-failing on them can't reintroduce that * flake. * - * Playwright lives in clients/web's node_modules, so it's resolved with a - * `createRequire` based at clients/web/package.json rather than a bare - * `import("playwright")`. A bare ESM specifier resolves relative to *this - * script's* directory (scripts/), not the cwd — so `cd clients/web` in the npm - * script would NOT make it resolvable (it only appeared to work locally when an - * ancestor node_modules happened to carry playwright; it fails in CI, which has - * none). createRequire pins resolution to clients/web regardless of cwd. + * Launching the browser (and resolving Playwright from clients/web, which has + * its own gotcha — see `lib/headless-browser.mjs`) is delegated to that module, + * which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the + * default), `firefox`, or `webkit`, all three of which CI runs (#2086). The + * engine question here is narrower than in the App smokes — this asserts a clean + * first paint, i.e. that the shipped bundle's syntax and API level are + * *reachable* on the engine at all, rather than anything about the sandbox — but + * it is real, and it is nearly free to include. * * Expects `clients/web/dist` and `clients/launcher/build` to be built first — * the validate / CI ordering guarantees this. */ -import { createRequire } from "node:module"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { startProdWebServer } from "./lib/prod-web-server.mjs"; +import { + attachPageDiagnostics, + loadBrowser, + resolveBrowserName, +} from "./lib/headless-browser.mjs"; -const scriptDir = dirname(fileURLToPath(import.meta.url)); -// Resolve playwright from clients/web (where it's installed) no matter the cwd. -const requireFromWeb = createRequire( - resolve(scriptDir, "..", "clients/web/package.json"), -); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// Resolved before the web server is started, so an unsupported SMOKE_BROWSER +// fails immediately. Every message carries the engine, so a matrix failure names +// which one broke. +let BROWSER; +try { + BROWSER = resolveBrowserName(); +} catch (err) { + console.error( + `smoke:web:browser FAILED — ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); +} +const LABEL = `smoke:web:browser [${BROWSER}]`; const HOST = "127.0.0.1"; // Distinct from smoke:web's SMOKE_WEB_PORT so overriding one doesn't make both @@ -72,17 +88,11 @@ const HOST = "127.0.0.1"; const PORT = process.env.SMOKE_WEB_BROWSER_PORT ?? "6298"; const TOKEN = "smoke-web-browser-token"; -// Console messages that are the async half of the uncaught-crash class (an -// unhandled promise rejection or a failed dynamic import). Hard failures, unlike -// benign console noise. See the header comment for why this can't reintroduce -// the font/CDN flake. -const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; - const server = startProdWebServer({ host: HOST, port: PORT, token: TOKEN, - label: "smoke:web:browser", + label: LABEL, }); let browser = null; @@ -99,51 +109,22 @@ async function shutdown() { } async function fail(message) { - console.error(`smoke:web:browser FAILED — ${message}`); + console.error(`${LABEL} FAILED — ${message}`); await shutdown(); process.exit(1); } -async function loadChromium() { - let chromium; - try { - ({ chromium } = requireFromWeb("playwright")); - } catch (err) { - // A failure here means the playwright *npm package* isn't resolvable from - // clients/web — fixed by installing devDependencies, NOT by `playwright - // install` (which fetches browser binaries). `npm install` at the repo root - // cascades into clients/web via postinstall. - 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 { await server.waitForReady(); - browser = await loadChromium(); + browser = await loadBrowser(repoRoot, BROWSER); const page = await browser.newPage(); // Uncaught (synchronous) page errors are a hard failure — a Node-only module // reaching the browser bundle surfaces here as a TypeError when its empty stub - // is called during module init. - const pageErrors = []; - // Console errors are diagnostic only EXCEPT those matching FATAL_CONSOLE (the - // async half of the same crash class) — see the header comment. - 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()); - }); + // is called during module init. Console errors are diagnostic only EXCEPT the + // async half of that same crash class; see the header comment, and the shared + // helper for the split. + const diagnostics = attachPageDiagnostics(page); const render = async () => { // Token is injected into index.html by the prod server, so a bare `/` load @@ -175,39 +156,35 @@ try { try { await Promise.race([server.whenChildExits(), render()]); } catch (err) { - const diagnostics = [ - ...pageErrors, - ...consoleErrors.map((m) => `console: ${m}`), + const notes = [ + ...diagnostics.pageErrors, + ...diagnostics.consoleErrors.map((m) => `console: ${m}`), ]; await fail( `${err instanceof Error ? err.message : String(err)}${ - diagnostics.length - ? ` — page diagnostics: ${diagnostics.join("; ")}` - : "" + notes.length ? ` — page diagnostics: ${notes.join("; ")}` : "" }`, ); } // Hard failures: any uncaught (sync) page error, plus console errors that are // the async half of the class (unhandled rejection / failed dynamic import). - const fatalConsole = consoleErrors.filter((m) => FATAL_CONSOLE.test(m)); - if (pageErrors.length > 0 || fatalConsole.length > 0) { - await fail( - `app logged uncaught error(s): ${[...pageErrors, ...fatalConsole].join("; ")}`, - ); + const fatal = diagnostics.fatal(); + 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 the smoke on benign subresource/warning noise. - const benignConsole = consoleErrors.filter((m) => !FATAL_CONSOLE.test(m)); - if (benignConsole.length > 0) { + const benign = diagnostics.benignConsole(); + if (benign.length > 0) { console.log( - `smoke:web:browser note — ${benignConsole.length} non-fatal console error(s): ${benignConsole.join("; ")}`, + `${LABEL} note — ${benign.length} non-fatal console error(s): ${benign.join("; ")}`, ); } console.log( - `smoke:web:browser OK — app booted at ${server.baseUrl}, rendered "Add Servers" with no uncaught errors (sync page error or unhandled rejection)`, + `${LABEL} OK — app booted at ${server.baseUrl}, rendered "Add Servers" with no uncaught errors (sync page error or unhandled rejection)`, ); await shutdown(); process.exit(0); diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 42599c0a11..19ba2c5d2b 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -22,9 +22,14 @@ * 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. + * proof to a PR); unset, it asserts only. + * + * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or + * `webkit`); CI runs all three (#2086). Along with `smoke:web:app` this is one + * of the two places the MCP Apps sandbox is actually loaded, and the two nested + * frames below are precisely the surface that diverges between engines. See + * `lib/headless-browser.mjs`, including why a green WebKit run is not a Safari + * guarantee. * * Expects `clients/web/dist` and `clients/launcher/build` to be built first. * `test-servers/build` is rebuilt on every run, as in smoke:web:app — see @@ -33,20 +38,35 @@ import { spawn } from "node:child_process"; import { 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 { + attachPageDiagnostics, + loadBrowser, + resolveBrowserName, +} from "./lib/headless-browser.mjs"; import { ensureTestServers, testServerEntryPath, } from "./lib/ensure-test-servers.mjs"; const repoRoot = resolve(import.meta.dirname, ".."); -const requireFromWeb = createRequire( - resolve(repoRoot, "clients/web/package.json"), -); + +// Resolved before anything is started, so an unsupported SMOKE_BROWSER fails +// immediately rather than after a web server and two MCP servers are up. Every +// message carries the engine, so a matrix failure names which one broke. +let BROWSER; +try { + BROWSER = resolveBrowserName(); +} catch (err) { + console.error( + `smoke:web:elicit FAILED — ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); +} +const LABEL = `smoke:web:elicit [${BROWSER}]`; const composableServer = testServerEntryPath(repoRoot, "composable"); const configPath = (name) => @@ -59,9 +79,6 @@ 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; -// The async half of the uncaught-crash class. Kept identical to -// smoke-web-browser.mjs / smoke-web-app.mjs. -const FATAL_CONSOLE = /^Uncaught\b|Failed to fetch dynamically imported module/; const servers = []; let browser = null; @@ -69,7 +86,7 @@ const web = startProdWebServer({ host: HOST, port: PORT, token: TOKEN, - label: "smoke:web:elicit", + label: LABEL, }); async function shutdown() { @@ -84,14 +101,14 @@ async function shutdown() { await web.stop(); while (servers.length) { await stopChild(servers.pop(), { - label: "smoke:web:elicit", + label: LABEL, what: "MCP test server", }); } } async function fail(message) { - console.error(`smoke:web:elicit FAILED — ${message}`); + console.error(`${LABEL} FAILED — ${message}`); await shutdown(); process.exit(1); } @@ -134,24 +151,6 @@ async function startMcpServer(configName) { 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 }); @@ -159,7 +158,7 @@ async function shot(page, name) { path: join(SHOT_DIR, `${name}.png`), fullPage: false, }); - console.log(`smoke:web:elicit — captured ${name}.png`); + console.log(`${LABEL} — captured ${name}.png`); } /** Connect to `mcpUrl` through the deep link and wait for the Tools list. */ @@ -207,31 +206,24 @@ try { // Rebuilt on every run — presence is not freshness (#2111). ensureTestServers({ repoRoot, - label: "smoke:web:elicit", + label: LABEL, requires: ["composable"], }); const appUrl = await startMcpServer("app-elicitation-http"); const nativeUrl = await startMcpServer("app-elicitation-native-http"); await web.waitForReady(); - browser = await loadChromium(); + browser = await loadBrowser(repoRoot, BROWSER); const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, }); - // Uncaught *synchronous* page errors, and their *async* twin (an unhandled - // rejection or a failed dynamic import), which Chromium reports on the - // console channel instead. Both are hard failures; every other console error - // is only a diagnostic, so benign font/React noise cannot flake CI. Same - // split as smoke:web:browser and smoke:web:app, which document it at length. - 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)); + // Uncaught *synchronous* page errors, plus their *async* twin (an unhandled + // rejection or a failed dynamic import), which arrives on the console channel + // instead. Both are hard failures; every other console error is only a + // diagnostic, so benign font/React noise cannot flake CI. Shared with + // smoke:web:app rather than re-hand-rolled — the split is subtle enough that + // two copies would drift, and it is documented at length on the helper. + const diagnostics = attachPageDiagnostics(page); const drive = async () => { // ── 1. Negotiated: the server's app answers the elicitation ──────────── @@ -297,27 +289,25 @@ try { try { await Promise.race([web.whenChildExits(), drive()]); } catch (err) { - const diagnostics = [ - ...pageErrors, - ...fatalConsole().map((m) => `console: ${m}`), + const notes = [ + ...diagnostics.pageErrors, + ...diagnostics.fatalConsole().map((m) => `console: ${m}`), ]; await fail( `${err instanceof Error ? err.message : String(err)}${ - diagnostics.length - ? ` — page diagnostics: ${diagnostics.join("; ")}` - : "" + notes.length ? ` — page diagnostics: ${notes.join("; ")}` : "" }`, ); } // A drive that reached all of its assertions still fails if the page threw on // the way: without this the smoke prints OK over a broken bundle. - const fatal = [...pageErrors, ...fatalConsole()]; + const fatal = diagnostics.fatal(); if (fatal.length > 0) { await fail(`page logged uncaught error(s): ${fatal.join("; ")}`); } - console.log("smoke:web:elicit OK — app-rendered and native paths both drive"); + console.log(`${LABEL} OK — app-rendered and native paths both drive`); await shutdown(); } catch (err) { await fail(err instanceof Error ? err.message : String(err)); From 822919f3ef649c1fb5ecc863d4ea999807b5fe80 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 20:52:43 -0400 Subject: [PATCH 002/213] docs(smoke): state the actual CI browser coverage, not the intended one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three smoke headers and one AGENTS.md bullet still said CI runs all three engines. That was written before pointing the smokes at WebKit found #2132; the matrix was then narrowed to Firefox and these were not brought along, so they claimed a gate that does not exist. Each now says what CI actually enforces — Chromium in the build job's `npm run smoke`, Firefox in the Sandbox smokes matrix — and where WebKit stands. smoke-web-browser.mjs additionally notes that IT passes in WebKit; the two App smokes are the ones that do not, and a reader of this file should not infer otherwise from a bare '#2132' reference. The fail-fast: false rationale no longer cites a WebKit-vs-Firefox example it cannot currently produce, and says why the flag is still right for a one-entry matrix. Addresses Copilot review on #2133. Signed-off-by: cliffhall --- AGENTS.md | 4 ++-- scripts/smoke-web-app.mjs | 7 ++++++- scripts/smoke-web-browser.mjs | 8 ++++++-- scripts/smoke-web-elicitation.mjs | 7 ++++++- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcc7d93565..8417344879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -908,7 +908,7 @@ 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 ` 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. -### The web smokes run in three browser engines (#2086) +### The web smokes are engine-parameterized; CI gates Chromium and Firefox (#2086) **`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** CI is green on Chromium and Firefox; **WebKit runs but is not in the CI matrix yet** — see the last bullet. `npm run smoke:web:engine` runs all three smokes in whichever engine is selected — that script, not the workflow YAML, is the list of engine-covered smokes, so adding a fourth covers every engine without touching CI. @@ -918,7 +918,7 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **No other tier can substitute, so don't propose one.** `sandbox-csp.test.ts` asserts which policy _string_ is built — environment-independent by construction, and it would pass identically on an engine that ignores `` CSP entirely. And **no Storybook story reaches the sandbox at all**: all three App stories (`AppRenderer`, `AppsScreen`, `AppElicitationHost`) point the iframe at a `data:` placeholder and hand the renderer a mock bridge, so `sandbox-csp.ts` is imported by exactly two things in the tree — its own test and `createAppBridgeFactory.ts`. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. - **An unrecognized `SMOKE_BROWSER` is an error, never a fallback.** Falling back to Chromium would report a green Chromium run under a job labelled `webkit` — coverage claimed but not run, which is worse than none. - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. -- **CI runs Chromium in the `build` job's `npm run smoke` and the rest in a `Sandbox smokes ()` matrix job** with `fail-fast: false`, so a WebKit-only regression is not hidden behind a Firefox-only one. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. +- **CI runs Chromium in the `build` job's `npm run smoke` and every other gated engine in a `Sandbox smokes ()` matrix job** — today that matrix is `[firefox]` alone (see the WebKit bullet below). It sets `fail-fast: false` so that once it holds more than one engine, a failure in one cannot hide a failure in another. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. - **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132) carries the diagnosis and a verified fix. Adding `webkit` to the matrix is a one-word diff once that lands; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. Worth noting the matrix found this on its first outing, against a bug every Chromium-only tier had been green through — and it is a _product_ bug, not a test one. - ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 81ba3199ef..8245075afe 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -44,7 +44,12 @@ * ── Which engine ──────────────────────────────────────────────────────────── * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`); CI runs all three (#2086). This smoke is one of the two places the + * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and + * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not + * gated yet — it fails on #2132, a Safari incompatibility in the web client's + * SSE transport that this smoke is what found (#2086). + * + * This smoke is one of the two places the * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the * primitives that actually diverge between engines — `srcdoc` CSP inheritance, * nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. See diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index aa50a5a359..d59547154c 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -46,8 +46,12 @@ * Launching the browser (and resolving Playwright from clients/web, which has * its own gotcha — see `lib/headless-browser.mjs`) is delegated to that module, * which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the - * default), `firefox`, or `webkit`, all three of which CI runs (#2086). The - * engine question here is narrower than in the App smokes — this asserts a clean + * default), `firefox`, or `webkit` (#2086). CI gates Chromium (in the `build` + * job's `npm run smoke`) and Firefox (in the `Sandbox smokes` matrix job); + * WebKit runs but is not gated yet, pending #2132 — note this smoke PASSES in + * WebKit, it is the two App smokes that do not. + * + * The engine question here is narrower than in the App smokes — this asserts a clean * first paint, i.e. that the shipped bundle's syntax and API level are * *reachable* on the engine at all, rather than anything about the sandbox — but * it is real, and it is nearly free to include. diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 19ba2c5d2b..3426ef2b3a 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -25,7 +25,12 @@ * proof to a PR); unset, it asserts only. * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`); CI runs all three (#2086). Along with `smoke:web:app` this is one + * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and + * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not + * gated yet — it fails on #2132, a Safari incompatibility in the web client's + * SSE transport that this smoke is what found (#2086). + * + * Along with `smoke:web:app` this is one * of the two places the MCP Apps sandbox is actually loaded, and the two nested * frames below are precisely the surface that diverges between engines. See * `lib/headless-browser.mjs`, including why a green WebKit run is not a Safari From 03efc2df1848a87bbf86d18454f621ebb06f02e3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 21:00:36 -0400 Subject: [PATCH 003/213] docs(smoke): finish the CI-topology correction in README and the workflow Round 1 fixed the three smoke headers and AGENTS.md but left the same overstatement in the two files Copilot flagged as previously-missed: - README said the matrix runs 'the remaining engines', which reads as Firefox AND WebKit and contradicts the very next paragraph. It now names the current matrix (Firefox alone) and points at that paragraph. - The workflow's fail-fast comment cited a WebKit-vs-Firefox scenario the one-entry matrix cannot produce. It now justifies keeping the flag for when a second engine returns, matching the AGENTS.md wording. Swept the whole tree for the remaining variants of the claim; none left. Addresses the suppressed comments on Copilot's second review of #2133. Signed-off-by: cliffhall --- .github/workflows/main.yml | 7 ++++--- README.md | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b967625858..09f16ec615 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -141,9 +141,10 @@ jobs: browser-engine-smokes: runs-on: ubuntu-latest strategy: - # Report every engine's verdict. Failing fast would hide a WebKit-only - # regression behind a Firefox-only one, which is the exact distinction - # this job exists to draw. + # Kept on despite the matrix currently holding one engine: the moment a + # second is added back (see below), failing fast would hide one engine's + # regression behind another's — which is the exact distinction this job + # exists to draw, so the flag should not have to be remembered then. fail-fast: false matrix: # `webkit` belongs here and is deliberately absent: the smokes RUN in it diff --git a/README.md b/README.md index 6e5280ce80..1855cc2066 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ SMOKE_BROWSER=webkit npm run smoke:web:app # one smoke, one engine SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine ``` -Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke` and the remaining engines in a separate `Sandbox smokes ()` matrix job, rather than running the whole `build` job once per engine (validate, the coverage gate, the two verify gates and Storybook are all engine-independent). An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. +Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke`, and every other gated engine in a separate `Sandbox smokes ()` matrix job — today that matrix holds **Firefox alone**; see the paragraph below for where WebKit stands. Splitting it that way, rather than running the whole `build` job once per engine, is deliberate: validate, the coverage gate, the two verify gates and Storybook are all engine-independent. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. **CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport, tracked with its diagnosis and a verified fix in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132). That is the matrix doing its job on its first outing: the bug affects the shipped web UI in Safari, and every Chromium-only tier had been green through it. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. From 25eb2fb3bf4756676369f85c4e3a92a67b72b16c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 22:32:47 -0400 Subject: [PATCH 004/213] docs(smoke): stop describing the WebKit smoke failure as a Safari bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2132 was filed asserting that a Safari user opening an MCP App gets a permanently 'loading' widget. That was never tested. A manual check in real Safari renders the App normally, so the claim is unsupported and is removed from all six places it reached. The failure under Playwright's WebKit is real and still keeps that engine out of the CI matrix. What is not established is that any shipping browser behaves this way — and Playwright ships its own WebKit build whose networking stack is not Safari's, which is exactly the layer the failure lives in. headless-browser.mjs already carried the caveat that a green WebKit run is not a Safari guarantee. The same sentence forbids the inverse inference, which is the one that was made; it now says so explicitly, and AGENTS.md carries the rule: reproduce in the real browser before claiming user impact in one. No behavior change. Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- .github/workflows/main.yml | 15 ++++++++------- AGENTS.md | 3 ++- README.md | 2 +- scripts/lib/headless-browser.mjs | 6 ++++++ scripts/smoke-web-app.mjs | 6 ++++-- scripts/smoke-web-elicitation.mjs | 6 ++++-- 7 files changed, 26 insertions(+), 14 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a3a09e87fd..eefdfa70b0 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -105,7 +105,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `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`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. 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. -- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix pending #2132, a real Safari SSE hang it found), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. +- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix pending #2132, an undelivered-SSE-tail hang under Playwright's WebKit — not reproduced in real Safari, so don't cite it as one), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. - **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_. - **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 09f16ec615..d08a8afc8b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -128,8 +128,8 @@ jobs: # # This is the only place the MCP Apps sandbox is exercised on a non-Chromium # engine, and it earned its keep immediately: pointing the smokes at WebKit is - # what found #2132, a Safari-only hang in the web client's SSE transport that - # every Chromium-only tier had been green through. + # what found #2132, an undelivered-SSE-tail hang no Chromium-only tier could + # see. (Note #2132 is NOT known to affect Safari — see the matrix comment.) # # The unit tests cannot substitute — `sandbox-csp.test.ts` asserts # which policy STRING is built, which passes identically on an engine that @@ -149,11 +149,12 @@ jobs: matrix: # `webkit` belongs here and is deliberately absent: the smokes RUN in it # (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the - # two App smokes fail on a real, pre-existing Safari incompatibility in - # the web client's SSE transport — #2132, which carries the diagnosis and - # the verified fix. Adding it here is a one-word diff once that lands. - # An engine is either green or absent; a `continue-on-error` job would - # report coverage nobody is holding to a standard. + # two App smokes fail on #2132 — the SSE stream's last message is not + # delivered, so an App never leaves "loading". That is NOT reproduced in + # real Safari, so read it as a property of Playwright's WebKit build + # rather than a browser bug. Adding it here is a one-word diff once it is + # resolved. An engine is either green or absent; a `continue-on-error` + # job would report coverage nobody is holding to a standard. browser: [firefox] name: Sandbox smokes (${{ matrix.browser }}) steps: diff --git a/AGENTS.md b/AGENTS.md index 8417344879..2f6e0dc348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -920,7 +920,8 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. - **CI runs Chromium in the `build` job's `npm run smoke` and every other gated engine in a `Sandbox smokes ()` matrix job** — today that matrix is `[firefox]` alone (see the WebKit bullet below). It sets `fail-fast: false` so that once it holds more than one engine, a failure in one cannot hide a failure in another. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. -- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132) carries the diagnosis and a verified fix. Adding `webkit` to the matrix is a one-word diff once that lands; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. Worth noting the matrix found this on its first outing, against a bug every Chromium-only tier had been green through — and it is a _product_ bug, not a test one. +- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132): the SSE stream's last message is not delivered, so an App never leaves "loading". Adding `webkit` to the matrix is a one-word diff once that is resolved; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. +- ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** #2132 was first written up as a Safari bug on the strength of a Playwright-WebKit failure alone; a manual check in Safari did not reproduce it. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. - ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. ### Build output is never a gate target diff --git a/README.md b/README.md index 1855cc2066..cfa170bfe5 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke`, and every other gated engine in a separate `Sandbox smokes ()` matrix job — today that matrix holds **Firefox alone**; see the paragraph below for where WebKit stands. Splitting it that way, rather than running the whole `build` job once per engine, is deliberate: validate, the coverage gate, the two verify gates and Storybook are all engine-independent. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. -**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there on a real, pre-existing Safari incompatibility in the web client's SSE transport, tracked with its diagnosis and a verified fix in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132). That is the matrix doing its job on its first outing: the bug affects the shipped web UI in Safari, and every Chromium-only tier had been green through it. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. +**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there, tracked in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132): the SSE stream's last message is not delivered, so an App never leaves "loading". **This has not been reproduced in real Safari** — an MCP App opens there normally — so on current evidence it is a property of Playwright's WebKit build (whose network stack is not Safari's) rather than a browser bug users hit. It still blocks gating the engine, and it is worth understanding before WebKit joins the matrix. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index f20ec266e5..d9282b09b9 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -27,6 +27,12 @@ * catch engine-level CSP and iframe divergence, and not close enough to certify * Safari specifically — a green run here is not a Safari guarantee. * + * **And that cuts both ways: a RED run here is not a Safari indictment.** #2132 + * was written up as a Safari bug on the strength of a failure in this build + * alone; a manual check in Safari did not reproduce it. The divergence is widest + * in the networking layer, which is exactly where that failure lives. Reproduce + * in the real browser before describing a finding as one users hit. + * * 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 a `cd clients/web` diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 8245075afe..edcd9ad500 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -46,8 +46,10 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132, a Safari incompatibility in the web client's - * SSE transport that this smoke is what found (#2086). + * gated yet — it fails on #2132, where the SSE stream's last message is not + * delivered under Playwright's WebKit. That has NOT been reproduced in real + * Safari (an MCP App opens there fine), so treat it as a property of that + * build, not as a browser bug, until someone shows otherwise (#2086). * * This smoke is one of the two places the * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 3426ef2b3a..be546eba54 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -27,8 +27,10 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132, a Safari incompatibility in the web client's - * SSE transport that this smoke is what found (#2086). + * gated yet — it fails on #2132, where the SSE stream's last message is not + * delivered under Playwright's WebKit. That has NOT been reproduced in real + * Safari (an MCP App opens there fine), so treat it as a property of that + * build, not as a browser bug, until someone shows otherwise (#2086). * * Along with `smoke:web:app` this is one * of the two places the MCP Apps sandbox is actually loaded, and the two nested From 4ab0139de92838b0144471a8df7d8278b100410e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 22:42:06 -0400 Subject: [PATCH 005/213] test(smoke): cover loadBrowser's failure branches; fix a stale helper comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Copilot's suppressed block. 1. The launch-failure message is #2086's acceptance criterion — a missing browser must name THAT engine and its own install command — and it had no test. The smokes cannot supply one: install-smoke-browser fetches the binary first, so a passing smoke says nothing about what a failing one prints, and that message is the deliverable. loadBrowser now takes an injectable loadPlaywright purely to open that seam. The test asserts per engine that the message leads with that engine, names --with-deps and no other, and preserves the underlying cause. Verified by mutation: hard-coding 'chromium' in the message fails it. It also pins the two branches apart — an unresolvable package must say > @modelcontextprotocol/inspector@2.3.0 postinstall > node scripts/install-clients.mjs [install-clients] Installing dependencies for clients/web... up to date, audited 583 packages in 614ms 198 packages are looking for funding run `npm fund` for details found 0 vulnerabilities [install-clients] Installing dependencies for clients/cli... up to date, audited 237 packages in 496ms 69 packages are looking for funding run `npm fund` for details found 0 vulnerabilities [install-clients] Installing dependencies for clients/tui... up to date, audited 314 packages in 518ms 102 packages are looking for funding run `npm fund` for details found 0 vulnerabilities [install-clients] Installing dependencies for clients/launcher... up to date, audited 160 packages in 449ms 50 packages are looking for funding run `npm fund` for details found 0 vulnerabilities up to date, audited 281 packages in 3s 108 packages are looking for funding run `npm fund` for details found 0 vulnerabilities and must NOT say , which fetches binaries and cannot fix a missing package. The unsupported-engine test now also asserts loadPlaywright was never called, pinning the ORDER rather than only the message: validating first is what makes the error name the real mistake. 2. prod-web-server.mjs's header still described the prefix this PR removed. Corrected, keeping why deriving paths from import.meta.url is still right, and adding the elicitation smoke to the list of callers it had also outgrown. Addresses the suppressed comments on Copilot's review of #2133. Signed-off-by: cliffhall --- scripts/lib/headless-browser.mjs | 47 ++++++++++---- scripts/lib/headless-browser.test.mjs | 91 ++++++++++++++++++++++++--- scripts/lib/prod-web-server.mjs | 22 ++++--- 3 files changed, 134 insertions(+), 26 deletions(-) diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index d9282b09b9..50ec7d9bde 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -112,30 +112,55 @@ export function resolveBrowserName(env = process.env) { return name; } +/** + * Resolve the Playwright package from the web client's install. + * + * Separate from `loadBrowser` so it can be substituted in tests — see the + * `loadPlaywright` option there for why that seam has to exist. + */ +export function requirePlaywright(repoRoot) { + const requireFromWeb = createRequire( + join(repoRoot, "clients", "web", "package.json"), + ); + return requireFromWeb("playwright"); +} + /** * Launch a headless browser of `browserName`, resolved from the web client's * install. * - * The launch-failure message names the engine that failed and the - * `playwright install` invocation that fixes it — naming "chromium" while - * WebKit was the thing missing sends the reader off to install a browser they - * already have. + * Both failure branches produce a message that names the actual remedy, and they + * are different remedies — which is the whole reason they are separate branches: + * + * - **not resolvable** means the Playwright *npm package* is missing, fixed by + * `npm install` at the repo root. `playwright install` (which fetches + * browser binaries) would not help and is the wrong thing to suggest. + * - **launch rejected** means the package is there but that engine's *binary* + * is not, fixed by `playwright install --with-deps `. Naming + * "chromium" while WebKit was the missing one sends the reader off to + * install a browser they already have — the reason the engine is + * interpolated rather than hard-coded (#2086's acceptance criterion). + * + * `loadPlaywright` is injectable purely so those branches can be unit-tested. + * They are unreachable from the smokes by construction: `install-smoke-browser` + * runs first and fetches the binary, so a passing smoke proves nothing about the + * message a failing one would print — and that message IS the deliverable here, + * since its whole job is to be read by someone whose setup is broken. */ -export async function loadBrowser(repoRoot, browserName = DEFAULT_BROWSER) { +export async function loadBrowser( + repoRoot, + browserName = DEFAULT_BROWSER, + { loadPlaywright = requirePlaywright } = {}, +) { if (!SUPPORTED_BROWSERS.includes(browserName)) { throw new Error( `unsupported browser "${browserName}" — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, ); } - const requireFromWeb = createRequire( - join(repoRoot, "clients", "web", "package.json"), - ); let playwright; try { - playwright = requireFromWeb("playwright"); + playwright = loadPlaywright(repoRoot); } 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)})`, ); diff --git a/scripts/lib/headless-browser.test.mjs b/scripts/lib/headless-browser.test.mjs index 187d02e7d7..c6b2d26351 100644 --- a/scripts/lib/headless-browser.test.mjs +++ b/scripts/lib/headless-browser.test.mjs @@ -8,9 +8,15 @@ * a fallback to Chromium there would report a green run under a job labelled * "webkit", claiming coverage that never ran. * - * `loadBrowser`'s own launch path is deliberately not covered here — it takes a - * real browser binary, which is what the smokes are for. Only its argument - * validation, which fails before any of that, is. + * `loadBrowser`'s failure branches are covered here too, through its injectable + * `loadPlaywright`. The smokes cannot reach them by construction — + * `install-smoke-browser` fetches the binary before the smoke runs, so a passing + * smoke says nothing about what a failing one would print. And that message is + * the deliverable: #2086's acceptance criterion is that a missing browser fails + * naming *that engine* and its own `playwright install` command, which is + * exactly the kind of string that rots into naming the wrong one. + * + * Only the successful launch is left to the smokes, since it needs a real binary. */ import assert from "node:assert/strict"; @@ -66,14 +72,85 @@ describe("resolveBrowserName", () => { }); describe("loadBrowser", () => { + /** A Playwright stand-in whose every engine rejects on launch. */ + const launchAlwaysFails = (message) => () => ({ + chromium: { launch: () => Promise.reject(new Error(message)) }, + firefox: { launch: () => Promise.reject(new Error(message)) }, + webkit: { launch: () => Promise.reject(new Error(message)) }, + }); + it("rejects an unsupported engine before touching Playwright", async () => { + let loaded = false; await assert.rejects( - // A repo root that does not exist: reaching the `createRequire` would - // throw a different (resolution) error, so this also pins the ORDER — - // validation first, so the message names the real mistake. - () => loadBrowser("/nonexistent-repo-root", "safari"), + () => + loadBrowser("/nonexistent-repo-root", "safari", { + loadPlaywright: () => { + loaded = true; + return {}; + }, + }), /unsupported browser "safari"/, ); + // Pins the ORDER, not just the message: validating first is what makes the + // error name the real mistake instead of a downstream resolution failure. + assert.equal(loaded, false); + }); + + it("names the engine that failed, and its own install command", async () => { + // The whole point of #2086's acceptance criterion: a reader whose WebKit + // binary is missing must not be sent to install chromium. + for (const name of SUPPORTED_BROWSERS) { + await assert.rejects( + () => + loadBrowser("/repo", name, { + loadPlaywright: launchAlwaysFails("Executable doesn't exist"), + }), + (err) => { + assert.match(err.message, new RegExp(`^${name} failed to launch`)); + assert.match( + err.message, + new RegExp( + `npx playwright install --with-deps ${name}\\\`(?![\\s\\S]*--with-deps (?!${name}))`, + ), + ); + // The underlying cause survives, so the reader can tell a missing + // binary from a sandbox/permissions problem. + assert.match(err.message, /Executable doesn't exist/); + // And no OTHER engine is named anywhere in the message. + for (const other of SUPPORTED_BROWSERS.filter((b) => b !== name)) { + assert.ok( + !err.message.includes(other), + `message for ${name} must not mention ${other}: ${err.message}`, + ); + } + return true; + }, + ); + } + }); + + it("sends an unresolvable Playwright to `npm install`, not `playwright install`", async () => { + // Two different failures with two different remedies. Suggesting + // `playwright install` here would be actively wrong — it fetches browser + // binaries and cannot install the missing npm package. + await assert.rejects( + () => + loadBrowser("/repo", "firefox", { + loadPlaywright: () => { + throw new Error("Cannot find module 'playwright'"); + }, + }), + (err) => { + assert.match(err.message, /could not resolve the Playwright package/); + assert.match(err.message, /npm install/); + assert.ok( + !/playwright install/.test(err.message), + `must not suggest \`playwright install\` for a missing package: ${err.message}`, + ); + assert.match(err.message, /Cannot find module 'playwright'/); + return true; + }, + ); }); }); diff --git a/scripts/lib/prod-web-server.mjs b/scripts/lib/prod-web-server.mjs index 895d95093f..1920267d3d 100644 --- a/scripts/lib/prod-web-server.mjs +++ b/scripts/lib/prod-web-server.mjs @@ -2,15 +2,21 @@ * Shared boot/readiness helper for the prod web smokes. * * `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. + * (runs-the-bundle check, #1615), `scripts/smoke-web-app.mjs` (MCP Apps + * end-to-end, #1859) and `scripts/smoke-web-elicitation.mjs` (app-rendered + * elicitations, #1854) 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 - * install` finds the local bin) doesn't affect which launcher/build tree runs. + * Repo-root paths are derived from import.meta.url rather than the cwd, so it + * does not matter where a caller is run from. (Until #2086 the browser-driven + * smokes were invoked through a `cd clients/web && npx playwright install …` + * prefix, which is what originally made that independence load-bearing; they now + * run from the repo root via `scripts/install-smoke-browser.mjs`. Deriving from + * import.meta.url is still the right call — it is what lets a smoke be run by + * hand from any directory.) */ import { spawn } from "node:child_process"; From 32cb4c63a7548542b815f657b4e6de9846aac614 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 22:52:02 -0400 Subject: [PATCH 006/213] fix(smoke): reject an empty SMOKE_BROWSER; test the installer's argument logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from Copilot's suppressed block. 1. resolveBrowserName treated a present-but-empty SMOKE_BROWSER as unset and returned chromium. The test that covered it even documented WHY that is dangerous — GitHub Actions renders an undefined matrix key as "" rather than omitting the variable — and then asserted the unsafe behavior. A misspelled matrix key would run chromium inside a job named for another engine and report green: the same false-coverage outcome the unrecognized-value branch exists to prevent, reached by a typo instead of a bad value. Only an absent variable now selects the default. Empty throws, naming the likely cause and the actual remedy ('unset it', which is not the same as setting it to nothing). The workflow always renders a real value, so nothing in CI is affected. 2. install-smoke-browser.mjs had no tests. Its I/O half runs on every smoke and fails loudly; its argument half does not — every caller passes nothing or the literal 'chromium', so the precedence rule is asserted by nothing at runtime. It matters: pack:verify passes chromium BECAUSE it launches chromium, and if the environment won, SMOKE_BROWSER=webkit npm run pack:verify would install one engine and launch another, surfacing as a misleading "Executable doesn't exist". resolveRequestedBrowser is now exported and pure; execution moved behind main() with the entrypoint guard verify-typecheck-coverage.mjs uses, so importing the module for tests cannot trigger an install. Both mutation-verified: restoring either behavior fails the tests that claim to cover it. Addresses the suppressed comments on Copilot's fifth review of #2133. Signed-off-by: cliffhall --- scripts/install-smoke-browser.mjs | 112 +++++++++++++++---------- scripts/install-smoke-browser.test.mjs | 88 +++++++++++++++++++ scripts/lib/headless-browser.mjs | 18 +++- scripts/lib/headless-browser.test.mjs | 26 ++++-- 4 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 scripts/install-smoke-browser.test.mjs diff --git a/scripts/install-smoke-browser.mjs b/scripts/install-smoke-browser.mjs index d052528c44..16502ec14f 100644 --- a/scripts/install-smoke-browser.mjs +++ b/scripts/install-smoke-browser.mjs @@ -21,64 +21,90 @@ * engine comes from `SMOKE_BROWSER` (default `chromium`); pass one explicitly * where the consumer's engine is fixed regardless of that variable, as * `pack:verify` does. + * + * Execution lives behind `main()` so importing this module for tests does not + * run an install — the same shape `verify-typecheck-coverage.mjs` uses. */ import { spawnSync } from "node:child_process"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import { SUPPORTED_BROWSERS, resolveBrowserName, } from "./lib/headless-browser.mjs"; import { resolveNodeBin } from "./lib/resolve-node-bin.mjs"; -const repoRoot = resolve(import.meta.dirname, ".."); -const webDir = join(repoRoot, "clients", "web"); - -const requested = process.argv[2]; -let browserName; -try { - if (requested === undefined) { - browserName = resolveBrowserName(); - } else if (SUPPORTED_BROWSERS.includes(requested)) { - browserName = requested; - } else { +/** + * Which engine to install: the explicit argument if given, else `SMOKE_BROWSER`. + * + * The precedence is the load-bearing part, and it is why this is a function + * rather than three lines inline. `pack:verify` passes `chromium` explicitly + * *because* it launches Chromium explicitly — if the argument ever lost to the + * environment, `SMOKE_BROWSER=webkit npm run pack:verify` would install WebKit + * and then launch Chromium, and the mismatch would surface as a confusing + * "Executable doesn't exist" rather than as the wiring error it is. + * + * An explicit argument is validated here rather than deferred, so a typo in an + * npm script fails naming itself instead of reaching the Playwright CLI. + */ +export function resolveRequestedBrowser(argv = [], env = process.env) { + const requested = argv[0]; + if (requested === undefined) return resolveBrowserName(env); + if (!SUPPORTED_BROWSERS.includes(requested)) { throw new Error( `unsupported browser "${requested}" — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, ); } -} catch (err) { - console.error( - `install-smoke-browser: ${err instanceof Error ? err.message : String(err)}`, - ); - process.exit(1); + return requested; } -let cli; -try { - cli = resolveNodeBin("playwright", "playwright", webDir); -} catch (err) { - console.error( - "install-smoke-browser: could not resolve the Playwright CLI from clients/web — " + - `run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, - ); - process.exit(1); -} +function main() { + const repoRoot = resolve(import.meta.dirname, ".."); + const webDir = join(repoRoot, "clients", "web"); -// `install` is a no-op when the binary is already present, so this is cheap on -// a warm machine and on a CI runner whose Playwright cache was restored. -const result = spawnSync(process.execPath, [cli, "install", browserName], { - cwd: webDir, - stdio: "inherit", -}); -if (result.error) { - console.error( - `install-smoke-browser: could not run the Playwright CLI: ${result.error.message}`, - ); - process.exit(1); -} -if (result.status !== 0) { - console.error( - `install-smoke-browser: \`playwright install ${browserName}\` exited ${result.status}`, - ); - process.exit(result.status ?? 1); + let browserName; + try { + browserName = resolveRequestedBrowser(process.argv.slice(2), process.env); + } catch (err) { + console.error( + `install-smoke-browser: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + let cli; + try { + cli = resolveNodeBin("playwright", "playwright", webDir); + } catch (err) { + console.error( + "install-smoke-browser: could not resolve the Playwright CLI from clients/web — " + + `run \`npm install\` at the repo root (${err instanceof Error ? err.message : String(err)})`, + ); + process.exit(1); + } + + // `install` is a no-op when the binary is already present, so this is cheap on + // a warm machine and on a CI runner whose Playwright cache was restored. + const result = spawnSync(process.execPath, [cli, "install", browserName], { + cwd: webDir, + stdio: "inherit", + }); + if (result.error) { + console.error( + `install-smoke-browser: could not run the Playwright CLI: ${result.error.message}`, + ); + process.exit(1); + } + if (result.status !== 0) { + console.error( + `install-smoke-browser: \`playwright install ${browserName}\` exited ${result.status}`, + ); + process.exit(result.status ?? 1); + } } + +// Only when run as a script, never on import (see the header). Same guard as +// verify-typecheck-coverage.mjs — `pathToFileURL` so it holds on Windows paths. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/install-smoke-browser.test.mjs b/scripts/install-smoke-browser.test.mjs new file mode 100644 index 0000000000..0b7bd58969 --- /dev/null +++ b/scripts/install-smoke-browser.test.mjs @@ -0,0 +1,88 @@ +/** + * Unit tests for the smoke browser installer's argument resolution (#2086). + * + * The installer's I/O half — resolving the Playwright CLI and spawning it — is + * exercised on every `npm run smoke:web:*`, so it fails loudly the moment it + * breaks. Its *argument* half is not: every caller in the repo passes either + * nothing or the literal `chromium`, so the precedence rule below is asserted by + * nothing at runtime, and getting it wrong produces a mismatch (install one + * engine, launch another) that surfaces as a misleading "Executable doesn't + * exist" rather than as the wiring error it is. + * + * Importing this module must not run an install; `main()` is behind an + * entrypoint guard for exactly that reason, and these tests are also what would + * catch that guard regressing — an unguarded module would try to install a + * browser the moment the suite imported it. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { resolveRequestedBrowser } from "./install-smoke-browser.mjs"; +import { + BROWSER_ENV_VAR, + DEFAULT_BROWSER, + SUPPORTED_BROWSERS, +} from "./lib/headless-browser.mjs"; + +describe("resolveRequestedBrowser", () => { + it("falls back to the environment when no argument is given", () => { + assert.equal(resolveRequestedBrowser([], {}), DEFAULT_BROWSER); + assert.equal( + resolveRequestedBrowser([], { [BROWSER_ENV_VAR]: "webkit" }), + "webkit", + ); + }); + + it("lets an explicit argument beat the environment", () => { + // The `pack:verify` invariant. It passes `chromium` because it LAUNCHES + // chromium; if the environment won here, `SMOKE_BROWSER=webkit npm run + // pack:verify` would install WebKit and then launch Chromium. + assert.equal( + resolveRequestedBrowser(["chromium"], { [BROWSER_ENV_VAR]: "webkit" }), + "chromium", + ); + for (const name of SUPPORTED_BROWSERS) { + assert.equal( + resolveRequestedBrowser([name], { [BROWSER_ENV_VAR]: "firefox" }), + name, + ); + } + }); + + it("rejects an unsupported argument, naming it and the allowed set", () => { + assert.throws( + () => resolveRequestedBrowser(["safari"], {}), + (err) => { + assert.match(err.message, /unsupported browser "safari"/); + SUPPORTED_BROWSERS.forEach((name) => + assert.ok(err.message.includes(name)), + ); + return true; + }, + ); + }); + + it("does not let a bad argument fall through to the environment", () => { + // Silently using SMOKE_BROWSER after ignoring a typo'd argument would + // install an engine nobody asked for, and the npm script's typo would + // survive unnoticed. + assert.throws( + () => + resolveRequestedBrowser(["chrome"], { [BROWSER_ENV_VAR]: "firefox" }), + /unsupported browser "chrome"/, + ); + }); + + it("propagates the environment's own validation, empty included", () => { + // Delegation, not a second copy of the rules — so the deny-by-default + // behavior cannot drift between the two entry points. + assert.throws( + () => resolveRequestedBrowser([], { [BROWSER_ENV_VAR]: "safari" }), + /is not a supported browser/, + ); + assert.throws( + () => resolveRequestedBrowser([], { [BROWSER_ENV_VAR]: "" }), + /set but empty/, + ); + }); +}); diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index 50ec7d9bde..84f148c8b9 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -99,11 +99,27 @@ export const BROWSER_ENV_VAR = "SMOKE_BROWSER"; * An unrecognized value FAILS rather than falling back to Chromium: a silent * fallback would report a green Chromium run under a job labelled "webkit", * which is worse than no coverage — it claims coverage that never ran. + * + * **Only an ABSENT variable selects the default. A present-but-empty one is an + * error**, because that is what an unresolved CI expression looks like: GitHub + * Actions renders `SMOKE_BROWSER: ${{ matrix.browsr }}` (or any undefined key) + * as the empty string rather than omitting the variable. Treating that as "not + * set" would run Chromium inside a job named for another engine — precisely the + * false-coverage failure this resolver exists to prevent, arriving through a + * typo instead of a bad value. Unsetting the variable is how you ask for the + * default; setting it to nothing is not. */ export function resolveBrowserName(env = process.env) { const raw = env[BROWSER_ENV_VAR]; - if (raw === undefined || raw.trim() === "") return DEFAULT_BROWSER; + if (raw === undefined) return DEFAULT_BROWSER; const name = raw.trim().toLowerCase(); + if (name === "") { + throw new Error( + `${BROWSER_ENV_VAR} is set but empty — an unresolved CI expression ` + + `(a misspelled \`matrix\` key renders as "") looks exactly like this. ` + + `Unset it to use ${DEFAULT_BROWSER}, or set one of ${SUPPORTED_BROWSERS.join(", ")}.`, + ); + } if (!SUPPORTED_BROWSERS.includes(name)) { throw new Error( `${BROWSER_ENV_VAR}="${raw}" is not a supported browser — expected one of ${SUPPORTED_BROWSERS.join(", ")}`, diff --git a/scripts/lib/headless-browser.test.mjs b/scripts/lib/headless-browser.test.mjs index c6b2d26351..bc9c2645ae 100644 --- a/scripts/lib/headless-browser.test.mjs +++ b/scripts/lib/headless-browser.test.mjs @@ -35,13 +35,25 @@ describe("resolveBrowserName", () => { assert.equal(resolveBrowserName({}), DEFAULT_BROWSER); }); - it("treats an empty or whitespace-only value as unset", () => { - // A CI expression that resolves to nothing (`SMOKE_BROWSER: ${{ … }}` with - // an undefined matrix key) sets the variable to "" rather than removing it. - for (const raw of ["", " "]) { - assert.equal( - resolveBrowserName({ [BROWSER_ENV_VAR]: raw }), - DEFAULT_BROWSER, + it("rejects a present-but-empty value instead of defaulting", () => { + // This is the case worth being strict about. A CI expression that resolves + // to nothing (`SMOKE_BROWSER: ${{ matrix.browsr }}`, an undefined key) sets + // the variable to "" rather than removing it — so defaulting here would run + // Chromium inside a job named for another engine, which is the very + // false-coverage outcome the unrecognized-value branch exists to stop. Only + // an ABSENT variable may select the default. + for (const raw of ["", " ", "\t\n"]) { + assert.throws( + () => resolveBrowserName({ [BROWSER_ENV_VAR]: raw }), + (err) => { + assert.match(err.message, /set but empty/); + // Names the likely cause and the actual remedy — "unset it", which is + // different from "set it to nothing" and is the whole point here. + assert.match(err.message, /matrix/); + assert.match(err.message, /Unset it/); + return true; + }, + `expected ${JSON.stringify(raw)} to be rejected`, ); } }); From d493b232b3c71b8815251ee14977f1601a6d0aaf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 23:02:43 -0400 Subject: [PATCH 007/213] docs(smoke): retract the #2132 mechanism claim; state that it is unknown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An isolated repro of the mechanism I described — a server writing three SSE frames then going silent with the connection held open, read via fetch + getReader — delivers every frame in all three engines, WebKit included. So 'the SSE stream's last message is not delivered' is not a supported description of the failure. The keep-alive experiment that appeared to confirm it was one observation, and the failure is not even stable in shape: two consecutive webkit runs failed at different stages (ready wait vs connect wait), which a deterministic buffering bug would not do. What is unchanged: the App smokes really do fail under Playwright's WebKit, which is why the engine stays out of the matrix; that reason never depended on the mechanism. The parseSSE cross-chunk defect also stands, being read off the code rather than inferred from an experiment. No behavior change. Signed-off-by: cliffhall --- .github/workflows/main.yml | 8 ++++---- AGENTS.md | 2 +- README.md | 2 +- scripts/smoke-web-app.mjs | 9 +++++---- scripts/smoke-web-elicitation.mjs | 9 +++++---- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d08a8afc8b..7a5a446f44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -149,10 +149,10 @@ jobs: matrix: # `webkit` belongs here and is deliberately absent: the smokes RUN in it # (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the - # two App smokes fail on #2132 — the SSE stream's last message is not - # delivered, so an App never leaves "loading". That is NOT reproduced in - # real Safari, so read it as a property of Playwright's WebKit build - # rather than a browser bug. Adding it here is a one-word diff once it is + # two App smokes fail on #2132, by a mechanism not yet identified — the + # first diagnosis was retracted when an isolated repro did not reproduce + # it. Not reproduced in real Safari either, so read it as a property of + # Playwright's WebKit build rather than a browser bug. Adding it here is a one-word diff once it is # resolved. An engine is either green or absent; a `continue-on-error` # job would report coverage nobody is holding to a standard. browser: [firefox] diff --git a/AGENTS.md b/AGENTS.md index 2f6e0dc348..d891e7b735 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -920,7 +920,7 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. - **CI runs Chromium in the `build` job's `npm run smoke` and every other gated engine in a `Sandbox smokes ()` matrix job** — today that matrix is `[firefox]` alone (see the WebKit bullet below). It sets `fail-fast: false` so that once it holds more than one engine, a failure in one cannot hide a failure in another. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. -- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132): the SSE stream's last message is not delivered, so an App never leaves "loading". Adding `webkit` to the matrix is a one-word diff once that is resolved; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. +- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132), by a mechanism that is **not yet identified**: the first diagnosis was retracted when an isolated repro failed to reproduce it, and the failure stage varies between runs. Adding `webkit` to the matrix is a one-word diff once that is resolved; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. - ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** #2132 was first written up as a Safari bug on the strength of a Playwright-WebKit failure alone; a manual check in Safari did not reproduce it. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. - ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. diff --git a/README.md b/README.md index cfa170bfe5..e5d19713ca 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke`, and every other gated engine in a separate `Sandbox smokes ()` matrix job — today that matrix holds **Firefox alone**; see the paragraph below for where WebKit stands. Splitting it that way, rather than running the whole `build` job once per engine, is deliberate: validate, the coverage gate, the two verify gates and Storybook are all engine-independent. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. -**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there, tracked in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132): the SSE stream's last message is not delivered, so an App never leaves "loading". **This has not been reproduced in real Safari** — an MCP App opens there normally — so on current evidence it is a property of Playwright's WebKit build (whose network stack is not Safari's) rather than a browser bug users hit. It still blocks gating the engine, and it is worth understanding before WebKit joins the matrix. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. +**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there, tracked in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132). **This has not been reproduced in real Safari** — an MCP App opens there normally — so on current evidence it is a property of Playwright's WebKit build rather than a browser bug users hit. The mechanism is not yet identified; see the issue, whose first diagnosis was retracted after an isolated repro failed to reproduce it. It still blocks gating the engine, and it is worth understanding before WebKit joins the matrix. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index edcd9ad500..2565ab23ae 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -46,10 +46,11 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132, where the SSE stream's last message is not - * delivered under Playwright's WebKit. That has NOT been reproduced in real - * Safari (an MCP App opens there fine), so treat it as a property of that - * build, not as a browser bug, until someone shows otherwise (#2086). + * gated yet — it fails on #2132 under Playwright's WebKit, by a mechanism that + * is NOT yet identified (the first diagnosis was retracted when an isolated + * repro did not reproduce it, and the failure stage varies between runs). It has + * NOT been reproduced in real Safari, so treat it as a property of that build + * rather than a browser bug until someone shows otherwise (#2086). * * This smoke is one of the two places the * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index be546eba54..fc53fead82 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -27,10 +27,11 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132, where the SSE stream's last message is not - * delivered under Playwright's WebKit. That has NOT been reproduced in real - * Safari (an MCP App opens there fine), so treat it as a property of that - * build, not as a browser bug, until someone shows otherwise (#2086). + * gated yet — it fails on #2132 under Playwright's WebKit, by a mechanism that + * is NOT yet identified (the first diagnosis was retracted when an isolated + * repro did not reproduce it, and the failure stage varies between runs). It has + * NOT been reproduced in real Safari, so treat it as a property of that build + * rather than a browser bug until someone shows otherwise (#2086). * * Along with `smoke:web:app` this is one * of the two places the MCP Apps sandbox is actually loaded, and the two nested From fb9e146043c1615df13bf3bbf617653eea967adc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 23:35:11 -0400 Subject: [PATCH 008/213] docs(smoke): drop the #2132 references now that the issue is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2132 was closed without the investigation going anywhere, so every 'pending #2132' pointer in the tree was about to send a reader to a closed issue whose own diagnosis had already been retracted. The docs now say what is actually known, and nothing more: the two App smokes fail under Playwright's WebKit for unidentified reasons; it does not reproduce in real Safari; an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either; and chasing it further was judged not worth the effort. A reader hitting it locally is told to treat it as unexplained rather than as a defect. The matrix decision is unchanged and never depended on the cause — the engine fails, so it is not gated. No behavior change. Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- .github/workflows/main.yml | 14 +++++++------- AGENTS.md | 4 ++-- README.md | 2 +- scripts/lib/headless-browser.mjs | 11 ++++++----- scripts/smoke-web-app.mjs | 11 ++++++----- scripts/smoke-web-browser.mjs | 4 ++-- scripts/smoke-web-elicitation.mjs | 11 ++++++----- 8 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index eefdfa70b0..a0795a0362 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -105,7 +105,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `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`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. 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. -- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix pending #2132, an undelivered-SSE-tail hang under Playwright's WebKit — not reproduced in real Safari, so don't cite it as one), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. +- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix — the App smokes fail under it for unidentified reasons that do not reproduce in real Safari, and chasing it was dropped), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. - **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_. - **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7a5a446f44..114eabc9d3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -127,9 +127,8 @@ jobs: # there plus whatever the matrix below names. # # This is the only place the MCP Apps sandbox is exercised on a non-Chromium - # engine, and it earned its keep immediately: pointing the smokes at WebKit is - # what found #2132, an undelivered-SSE-tail hang no Chromium-only tier could - # see. (Note #2132 is NOT known to affect Safari — see the matrix comment.) + # engine — which is why Firefox is gated here rather than assumed to behave + # like Chromium. # # The unit tests cannot substitute — `sandbox-csp.test.ts` asserts # which policy STRING is built, which passes identically on an engine that @@ -149,10 +148,11 @@ jobs: matrix: # `webkit` belongs here and is deliberately absent: the smokes RUN in it # (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the - # two App smokes fail on #2132, by a mechanism not yet identified — the - # first diagnosis was retracted when an isolated repro did not reproduce - # it. Not reproduced in real Safari either, so read it as a property of - # Playwright's WebKit build rather than a browser bug. Adding it here is a one-word diff once it is + # two App smokes fail there for reasons nobody has identified. It does + # NOT reproduce in real Safari, and an isolated SSE repro did not + # reproduce it under Playwright's WebKit either, so it reads as a + # property of that build rather than a browser bug — and chasing it + # further was judged not worth the effort. Adding it here is a one-word diff once it is # resolved. An engine is either green or absent; a `continue-on-error` # job would report coverage nobody is holding to a standard. browser: [firefox] diff --git a/AGENTS.md b/AGENTS.md index d891e7b735..374d2d145e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -920,8 +920,8 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. - **CI runs Chromium in the `build` job's `npm run smoke` and every other gated engine in a `Sandbox smokes ()` matrix job** — today that matrix is `[firefox]` alone (see the WebKit bullet below). It sets `fail-fast: false` so that once it holds more than one engine, a failure in one cannot hide a failure in another. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. -- **WebKit is deliberately out of the CI matrix for now, and `continue-on-error` is not the answer.** The two App smokes fail there — [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132), by a mechanism that is **not yet identified**: the first diagnosis was retracted when an isolated repro failed to reproduce it, and the failure stage varies between runs. Adding `webkit` to the matrix is a one-word diff once that is resolved; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. -- ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** #2132 was first written up as a Safari bug on the strength of a Playwright-WebKit failure alone; a manual check in Safari did not reproduce it. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. +- **WebKit is deliberately out of the CI matrix, and `continue-on-error` is not the answer.** The two App smokes fail under it for reasons nobody has identified — the failure stage even varies between runs — and the investigation was dropped as not worth the effort once it became clear it does not reproduce in real Safari, and that an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either. Adding `webkit` to the matrix is a one-word diff if someone ever does chase it down; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. +- ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** That failure was first written up as a Safari bug on the strength of a Playwright-WebKit run alone; a manual check in Safari did not reproduce it, and neither did an isolated repro of the mechanism it was blamed on. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. - ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. ### Build output is never a gate target diff --git a/README.md b/README.md index e5d19713ca..7872c84690 100644 --- a/README.md +++ b/README.md @@ -481,7 +481,7 @@ SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke`, and every other gated engine in a separate `Sandbox smokes ()` matrix job — today that matrix holds **Firefox alone**; see the paragraph below for where WebKit stands. Splitting it that way, rather than running the whole `build` job once per engine, is deliberate: validate, the coverage gate, the two verify gates and Storybook are all engine-independent. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. -**CI green today: Chromium and Firefox.** WebKit is supported by the tooling and worth running locally, but it is **not yet in the CI matrix** — the two App smokes fail there, tracked in [#2132](https://github.com/modelcontextprotocol/inspector/issues/2132). **This has not been reproduced in real Safari** — an MCP App opens there normally — so on current evidence it is a property of Playwright's WebKit build rather than a browser bug users hit. The mechanism is not yet identified; see the issue, whose first diagnosis was retracted after an isolated repro failed to reproduce it. It still blocks gating the engine, and it is worth understanding before WebKit joins the matrix. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. +**CI gates Chromium and Firefox. WebKit is supported by the tooling but is not gated**, because the two App smokes fail under it for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort. Run WebKit locally if you want the extra signal; treat a failure there as unexplained rather than as a defect until someone has looked. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index 84f148c8b9..daaaad03c6 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -27,11 +27,12 @@ * catch engine-level CSP and iframe divergence, and not close enough to certify * Safari specifically — a green run here is not a Safari guarantee. * - * **And that cuts both ways: a RED run here is not a Safari indictment.** #2132 - * was written up as a Safari bug on the strength of a failure in this build - * alone; a manual check in Safari did not reproduce it. The divergence is widest - * in the networking layer, which is exactly where that failure lives. Reproduce - * in the real browser before describing a finding as one users hit. + * **And that cuts both ways: a RED run here is not a Safari indictment.** The + * WebKit App-smoke failure was once written up as a Safari bug on the strength + * of a failure in this build alone; a manual check in Safari did not reproduce + * it, an isolated repro of the claimed mechanism did not reproduce it here + * either, and the whole line of investigation was dropped. Reproduce in the real + * browser before describing a finding as one users hit. * * Playwright is resolved with a `createRequire` based at * clients/web/package.json rather than a bare `import("playwright")`: a bare ESM diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 2565ab23ae..a93caa8e8c 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -46,11 +46,12 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132 under Playwright's WebKit, by a mechanism that - * is NOT yet identified (the first diagnosis was retracted when an isolated - * repro did not reproduce it, and the failure stage varies between runs). It has - * NOT been reproduced in real Safari, so treat it as a property of that build - * rather than a browser bug until someone shows otherwise (#2086). + * gated: this smoke fails under Playwright's WebKit for reasons nobody has + * identified, and nobody is currently investigating. It does NOT reproduce in + * real Safari, and an isolated SSE repro did not reproduce it in Playwright's + * WebKit either — so it is a property of that build, not a browser bug, and it + * was judged not worth chasing. Run WebKit locally if you want the signal; do + * not read a failure here as a defect until someone has looked (#2086). * * This smoke is one of the two places the * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index d59547154c..987bfe3d5a 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -48,8 +48,8 @@ * which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the * default), `firefox`, or `webkit` (#2086). CI gates Chromium (in the `build` * job's `npm run smoke`) and Firefox (in the `Sandbox smokes` matrix job); - * WebKit runs but is not gated yet, pending #2132 — note this smoke PASSES in - * WebKit, it is the two App smokes that do not. + * WebKit runs but is not gated — note this smoke PASSES in WebKit, it is the two + * App smokes that do not (see their headers). * * The engine question here is narrower than in the App smokes — this asserts a clean * first paint, i.e. that the shipped bundle's syntax and API level are diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index fc53fead82..9e82801aa4 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -27,11 +27,12 @@ * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated yet — it fails on #2132 under Playwright's WebKit, by a mechanism that - * is NOT yet identified (the first diagnosis was retracted when an isolated - * repro did not reproduce it, and the failure stage varies between runs). It has - * NOT been reproduced in real Safari, so treat it as a property of that build - * rather than a browser bug until someone shows otherwise (#2086). + * gated: this smoke fails under Playwright's WebKit for reasons nobody has + * identified, and nobody is currently investigating. It does NOT reproduce in + * real Safari, and an isolated SSE repro did not reproduce it in Playwright's + * WebKit either — so it is a property of that build, not a browser bug, and it + * was judged not worth chasing. Run WebKit locally if you want the signal; do + * not read a failure here as a defect until someone has looked (#2086). * * Along with `smoke:web:app` this is one * of the two places the MCP Apps sandbox is actually loaded, and the two nested From 8a7ade7b08cb25179187935c20f0ca8c7699bb52 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 25 Aug 2026 23:54:51 -0400 Subject: [PATCH 009/213] ci: drop the Firefox smoke job; keep the engines as an on-demand tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job was trialled and did not earn its place. It was genuinely cheap — ~2 minutes, running in parallel with the 15-minute build job, so zero added wall-clock — but across a dozen runs Firefox never once disagreed with Chromium. It was paying runner time and carrying a real flake surface (playwright install --with-deps runs apt-get update, which fails whenever a third-party repo in the runner image breaks; that is what the one red run was) to re-confirm a result already in hand. Everything that made cross-engine checking possible stays: SMOKE_BROWSER, the shared headless-browser helper, the installer, and the tests. So "SMOKE_BROWSER=firefox npm run smoke:web:engine" is there for the moment it is worth running — before touching the sandbox, the CSP builder or the proxy page — without taxing every push to re-derive the same answer. The workflow is now byte-identical to v2/main again; the publish jobs go back to needing only build. Docs say what is true rather than what was intended, and AGENTS.md records the reasoning so the job is not re-added on the general argument that cross-engine coverage is good — that argument was already accepted, and the on-demand path is what serves it. Re-add it on evidence: a real cross-engine regression a gate would have caught. Also swept the now-stale "matrix" language out of the smokes and the resolver, including the empty-SMOKE_BROWSER rationale, which no longer leans on a matrix key that does not exist. Signed-off-by: cliffhall --- .github/copilot-instructions.md | 2 +- .github/workflows/main.yml | 94 ++------------------------- AGENTS.md | 8 +-- README.md | 6 +- scripts/lib/headless-browser.mjs | 19 +++--- scripts/lib/headless-browser.test.mjs | 22 +++---- scripts/pack-and-verify.mjs | 2 +- scripts/smoke-web-app.mjs | 21 +++--- scripts/smoke-web-browser.mjs | 11 ++-- scripts/smoke-web-elicitation.mjs | 19 +++--- 10 files changed, 64 insertions(+), 140 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a0795a0362..34855be7cf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -105,7 +105,7 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo - `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `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`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. 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. -- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit) and CI covers Chromium plus Firefox (#2086; WebKit runs locally but is out of the matrix — the App smokes fail under it for unidentified reasons that do not reproduce in real Safari, and chasing it was dropped), because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. +- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit). **CI runs Chromium only**; the others are an on-demand tool, deliberately not a gate (#2086 — a Firefox job was trialled and removed for never disagreeing with Chromium). They exist because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. - **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_. - **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 114eabc9d3..a71918807c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,8 +28,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: "22.x" - cache: "npm" + node-version: '22.x' + cache: 'npm' - name: Install dependencies (root + all clients) # The root postinstall (scripts/install-clients.mjs) cascades @@ -115,86 +115,6 @@ jobs: working-directory: ./clients/web run: npm run test:storybook - # The same headless web smokes the `build` job already runs in Chromium, run - # again in the other engines this repo supports (#2086). - # - # Why this is a job of its own rather than a matrix over `build`: `build` also - # runs validate, the coverage gate, two verify gates and Storybook, none of - # which are engine-dependent — matrixing it would triple all of that to gain - # three browser smokes. Chromium is deliberately NOT in the matrix here for - # the same reason: `npm run smoke` inside `build` is exactly the local - # `npm run ci` path, and it already covers it. Supported set = the Chromium run - # there plus whatever the matrix below names. - # - # This is the only place the MCP Apps sandbox is exercised on a non-Chromium - # engine — which is why Firefox is gated here rather than assumed to behave - # like Chromium. - # - # The unit tests cannot substitute — `sandbox-csp.test.ts` asserts - # which policy STRING is built, which passes identically on an engine that - # ignores `` CSP entirely — and no Storybook story reaches the sandbox at - # all (all three App stories use a `data:` placeholder iframe and a mock - # bridge). Note Playwright's WebKit is a WebKit build, not Safari: close enough - # to catch engine-level CSP and iframe divergence, not close enough to certify - # Safari. - browser-engine-smokes: - runs-on: ubuntu-latest - strategy: - # Kept on despite the matrix currently holding one engine: the moment a - # second is added back (see below), failing fast would hide one engine's - # regression behind another's — which is the exact distinction this job - # exists to draw, so the flag should not have to be remembered then. - fail-fast: false - matrix: - # `webkit` belongs here and is deliberately absent: the smokes RUN in it - # (`SMOKE_BROWSER=webkit` works, and smoke:web:browser passes), but the - # two App smokes fail there for reasons nobody has identified. It does - # NOT reproduce in real Safari, and an isolated SSE repro did not - # reproduce it under Playwright's WebKit either, so it reads as a - # property of that build rather than a browser bug — and chasing it - # further was judged not worth the effort. Adding it here is a one-word diff once it is - # resolved. An engine is either green or absent; a `continue-on-error` - # job would report coverage nobody is holding to a standard. - browser: [firefox] - name: Sandbox smokes (${{ matrix.browser }}) - steps: - - name: Checkout code - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: "22.x" - cache: "npm" - - - name: Install dependencies (root + all clients) - run: npm install - - - name: Build all clients - # The smokes need clients/web/dist and the cli/tui/launcher bundles. - # `build` rather than `validate` — the format/lint/typecheck half is - # engine-independent and already ran in the `build` job. - run: npm run build - - - name: Cache Playwright browsers - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ matrix.browser }}-${{ runner.os }}-${{ hashFiles('clients/web/package-lock.json') }} - - - name: Install Playwright ${{ matrix.browser }} - working-directory: ./clients/web - # `--with-deps` for the system libraries a bare runner lacks; WebKit is - # the large download here and dominates this step. - run: npx playwright install --with-deps ${{ matrix.browser }} - - - name: Run the headless web smokes in ${{ matrix.browser }} - # The set of smokes lives in the `smoke:web:engine` npm script, not - # enumerated here, so adding one covers every engine automatically. - env: - SMOKE_BROWSER: ${{ matrix.browser }} - run: npm run smoke:web:engine - # Publish the single `@modelcontextprotocol/inspector` package to npm on a # published GitHub release. v2 is not an npm workspace, so there is no # `publish-all` / `--workspaces` (v1) — just one `npm publish`, whose `prepack` @@ -207,7 +127,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release - needs: [build, browser-engine-smokes] + needs: build # Serialize publishes so two releases cut in quick succession can't run # overlapping `npm publish`es. Never cancel an in-flight publish. concurrency: @@ -225,9 +145,9 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: "22.x" - cache: "npm" - registry-url: "https://registry.npmjs.org" + node-version: '22.x' + cache: 'npm' + registry-url: 'https://registry.npmjs.org' - name: Assert release tag matches package version # `npm publish` ships whatever `version` is in the root package.json, @@ -300,7 +220,7 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' environment: release - needs: [build, browser-engine-smokes] + needs: build permissions: contents: read packages: write diff --git a/AGENTS.md b/AGENTS.md index 374d2d145e..547063145f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -908,9 +908,9 @@ 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 ` 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. -### The web smokes are engine-parameterized; CI gates Chromium and Firefox (#2086) +### The web smokes are engine-parameterized; CI runs Chromium only (#2086) -**`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** CI is green on Chromium and Firefox; **WebKit runs but is not in the CI matrix yet** — see the last bullet. `npm run smoke:web:engine` runs all three smokes in whichever engine is selected — that script, not the workflow YAML, is the list of engine-covered smokes, so adding a fourth covers every engine without touching CI. +**`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** **CI runs Chromium only** — the other engines are an on-demand tool, not a gate (see the CI bullet for why). `npm run smoke:web:engine` runs all three smokes in whichever engine is selected — that script, not the workflow YAML, is the list of engine-covered smokes, so adding a fourth covers every engine without touching CI. Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** — `resolveBrowserName`, `loadBrowser`, and the `attachPageDiagnostics` / `FATAL_CONSOLE` split the smokes had each hand-rolled. Reach for it rather than launching Playwright in a new script. @@ -918,9 +918,9 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **No other tier can substitute, so don't propose one.** `sandbox-csp.test.ts` asserts which policy _string_ is built — environment-independent by construction, and it would pass identically on an engine that ignores `` CSP entirely. And **no Storybook story reaches the sandbox at all**: all three App stories (`AppRenderer`, `AppsScreen`, `AppElicitationHost`) point the iframe at a `data:` placeholder and hand the renderer a mock bridge, so `sandbox-csp.ts` is imported by exactly two things in the tree — its own test and `createAppBridgeFactory.ts`. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. - **An unrecognized `SMOKE_BROWSER` is an error, never a fallback.** Falling back to Chromium would report a green Chromium run under a job labelled `webkit` — coverage claimed but not run, which is worse than none. - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. -- **CI runs Chromium in the `build` job's `npm run smoke` and every other gated engine in a `Sandbox smokes ()` matrix job** — today that matrix is `[firefox]` alone (see the WebKit bullet below). It sets `fail-fast: false` so that once it holds more than one engine, a failure in one cannot hide a failure in another. Chromium is deliberately _not_ in that matrix — `build`'s `npm run smoke` is exactly the local `npm run ci` path and already covers it, and matrixing `build` itself would triple validate, the coverage gate, two verify gates and Storybook to gain three browser smokes. Both `publish` jobs `needs` the matrix, so a release cannot ship past a non-Chromium failure. +- **CI is Chromium-only, deliberately, and a Firefox job was trialled and removed.** The job was cheap — ~2 minutes, parallel with the 15-minute `build` job, so zero added wall-clock — and it still did not earn its place: across a dozen runs Firefox never once disagreed with Chromium, so it spent runner time and carried a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result already in hand. **Don't re-add it on the argument that cross-engine coverage is good in principle** — that argument was already accepted, and it is what the on-demand path serves. Re-add it on evidence: an actual cross-engine regression that a gate would have caught. It is a one-job diff when that happens. - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. -- **WebKit is deliberately out of the CI matrix, and `continue-on-error` is not the answer.** The two App smokes fail under it for reasons nobody has identified — the failure stage even varies between runs — and the investigation was dropped as not worth the effort once it became clear it does not reproduce in real Safari, and that an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either. Adding `webkit` to the matrix is a one-word diff if someone ever does chase it down; until then an engine is either green in CI or absent from it, because a job allowed to fail reports coverage nobody is held to. +- **WebKit fails the two App smokes**, for reasons nobody has identified — the failure stage even varies between runs. The investigation was dropped as not worth the effort once it became clear it does not reproduce in real Safari, and that an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either. Treat a WebKit failure as unexplained rather than as a defect until someone has actually looked. - ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** That failure was first written up as a Safari bug on the strength of a Playwright-WebKit run alone; a manual check in Safari did not reproduce it, and neither did an isolated repro of the mechanism it was blamed on. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. - ⚠️ **Playwright's WebKit is a WebKit build, not Safari.** Close enough to catch engine-level CSP and iframe divergence; not close enough to certify Safari. Don't write, in a doc or a PR description, that a green run means Safari works. diff --git a/README.md b/README.md index 7872c84690..092fa83739 100644 --- a/README.md +++ b/README.md @@ -479,9 +479,11 @@ SMOKE_BROWSER=webkit npm run smoke:web:app # one smoke, one engine SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine ``` -Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged. CI runs Chromium inside the `build` job's `npm run smoke`, and every other gated engine in a separate `Sandbox smokes ()` matrix job — today that matrix holds **Firefox alone**; see the paragraph below for where WebKit stands. Splitting it that way, rather than running the whole `build` job once per engine, is deliberate: validate, the coverage gate, the two verify gates and Storybook are all engine-independent. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run under a job labelled `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. +Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged, and **CI runs Chromium only**. The other engines are a tool you reach for, not a gate: running the sandbox smokes under Firefox before touching the MCP Apps sandbox, the CSP builder, or the proxy page is cheap and worth doing, but nothing runs it for you. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. -**CI gates Chromium and Firefox. WebKit is supported by the tooling but is not gated**, because the two App smokes fail under it for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort. Run WebKit locally if you want the extra signal; treat a failure there as unexplained rather than as a defect until someone has looked. An engine is either green in CI or absent from it — a `continue-on-error` job would report coverage nobody is held to. +**Firefox passes all three smokes. WebKit fails the two App smokes**, for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort — treat a WebKit failure as unexplained rather than as a defect until someone has looked. + +**Why none of this is gated in CI.** A Firefox job was trialled and was cheap — about two minutes, running in parallel with the 15-minute `build` job, so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it was paying real runner time and a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result we already had. Making it available on demand keeps the value — a cross-engine check right when you are changing engine-sensitive code — without a permanent tax on every push. If a cross-engine regression ever does turn up, that is the evidence for putting the job back; it is a one-job diff. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index daaaad03c6..d02de65e4c 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -102,13 +102,14 @@ export const BROWSER_ENV_VAR = "SMOKE_BROWSER"; * which is worse than no coverage — it claims coverage that never ran. * * **Only an ABSENT variable selects the default. A present-but-empty one is an - * error**, because that is what an unresolved CI expression looks like: GitHub - * Actions renders `SMOKE_BROWSER: ${{ matrix.browsr }}` (or any undefined key) - * as the empty string rather than omitting the variable. Treating that as "not - * set" would run Chromium inside a job named for another engine — precisely the - * false-coverage failure this resolver exists to prevent, arriving through a - * typo instead of a bad value. Unsetting the variable is how you ask for the - * default; setting it to nothing is not. + * error**, because empty is what a variable set from something that did not + * resolve looks like — `SMOKE_BROWSER="$ENGINE"` with `ENGINE` unset, or a CI + * expression naming a key that does not exist (GitHub Actions renders those as + * the empty string rather than omitting the variable). Treating that as "not + * set" would silently run Chromium for a caller who asked for something else, + * which is the same false-coverage failure the unrecognized-value branch exists + * to prevent, arriving through a typo instead of a bad value. Unsetting the + * variable is how you ask for the default; setting it to nothing is not. */ export function resolveBrowserName(env = process.env) { const raw = env[BROWSER_ENV_VAR]; @@ -116,8 +117,8 @@ export function resolveBrowserName(env = process.env) { const name = raw.trim().toLowerCase(); if (name === "") { throw new Error( - `${BROWSER_ENV_VAR} is set but empty — an unresolved CI expression ` + - `(a misspelled \`matrix\` key renders as "") looks exactly like this. ` + + `${BROWSER_ENV_VAR} is set but empty — a variable set from something ` + + `that did not resolve looks exactly like this. ` + `Unset it to use ${DEFAULT_BROWSER}, or set one of ${SUPPORTED_BROWSERS.join(", ")}.`, ); } diff --git a/scripts/lib/headless-browser.test.mjs b/scripts/lib/headless-browser.test.mjs index bc9c2645ae..02fcf2d49e 100644 --- a/scripts/lib/headless-browser.test.mjs +++ b/scripts/lib/headless-browser.test.mjs @@ -5,8 +5,8 @@ * per process and then spends its whole run inside the happy path, so the branch * that matters most — an unrecognized `SMOKE_BROWSER` — is dead code from their * point of view. It is also the branch whose failure is silent rather than loud: - * a fallback to Chromium there would report a green run under a job labelled - * "webkit", claiming coverage that never ran. + * a fallback to Chromium there would report a green run to a caller who asked + * for another engine — coverage claimed that never ran. * * `loadBrowser`'s failure branches are covered here too, through its injectable * `loadPlaywright`. The smokes cannot reach them by construction — @@ -36,20 +36,20 @@ describe("resolveBrowserName", () => { }); it("rejects a present-but-empty value instead of defaulting", () => { - // This is the case worth being strict about. A CI expression that resolves - // to nothing (`SMOKE_BROWSER: ${{ matrix.browsr }}`, an undefined key) sets - // the variable to "" rather than removing it — so defaulting here would run - // Chromium inside a job named for another engine, which is the very - // false-coverage outcome the unrecognized-value branch exists to stop. Only - // an ABSENT variable may select the default. + // This is the case worth being strict about. `SMOKE_BROWSER="$ENGINE"` with + // ENGINE unset — or a CI expression naming a key that does not exist — sets + // the variable to "" rather than removing it, so defaulting here would + // silently run Chromium for a caller who asked for something else. That is + // the same false-coverage outcome the unrecognized-value branch exists to + // stop. Only an ABSENT variable may select the default. for (const raw of ["", " ", "\t\n"]) { assert.throws( () => resolveBrowserName({ [BROWSER_ENV_VAR]: raw }), (err) => { assert.match(err.message, /set but empty/); - // Names the likely cause and the actual remedy — "unset it", which is - // different from "set it to nothing" and is the whole point here. - assert.match(err.message, /matrix/); + // Names the actual remedy — "unset it", which is different from + // "set it to nothing" and is the whole point here. + assert.match(err.message, /did not resolve/); assert.match(err.message, /Unset it/); return true; }, diff --git a/scripts/pack-and-verify.mjs b/scripts/pack-and-verify.mjs index 1145f4b22b..04538775c5 100644 --- a/scripts/pack-and-verify.mjs +++ b/scripts/pack-and-verify.mjs @@ -567,7 +567,7 @@ async function verifyAppRender(baseUrl, token, whenWebServerExits) { label: LABEL, }); // Chromium explicitly, not `resolveBrowserName()`: this is a *packaging* - // check, and the engine matrix (#2086) belongs to the smokes, where the + // check, and the engine question (#2086) belongs to the smokes, where the // sandbox surface is what is under test. Pinning it also means `pack:verify` // cannot be pointed at an engine its npm script never installed. browser = await loadBrowser(repoRoot, "chromium"); diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index a93caa8e8c..72720427f8 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -44,14 +44,15 @@ * ── Which engine ──────────────────────────────────────────────────────────── * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and - * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated: this smoke fails under Playwright's WebKit for reasons nobody has - * identified, and nobody is currently investigating. It does NOT reproduce in - * real Safari, and an isolated SSE repro did not reproduce it in Playwright's - * WebKit either — so it is a property of that build, not a browser bug, and it - * was judged not worth chasing. Run WebKit locally if you want the signal; do - * not read a failure here as a defect until someone has looked (#2086). + * `webkit`). **CI runs Chromium only.** The other engines are an on-demand tool, + * not a gate: `SMOKE_BROWSER=firefox npm run smoke:web:engine` before touching + * the sandbox is cheap and worth doing, but nothing runs it for you (#2086). + * + * Firefox passes. WebKit fails this smoke for reasons nobody has identified and + * nobody is investigating: it does not reproduce in real Safari, and an isolated + * SSE repro did not reproduce it under Playwright's WebKit either, so it reads + * as a property of that build rather than a browser bug. Do not read a WebKit + * failure here as a defect until someone has actually looked. * * This smoke is one of the two places the * MCP Apps sandbox is genuinely exercised, and the sandbox is built out of the @@ -89,8 +90,8 @@ const repoRoot = resolve(import.meta.dirname, ".."); // Resolved before anything is started, so an unsupported SMOKE_BROWSER fails // immediately rather than after a web server and two MCP servers are up. Every -// message this smoke prints carries the engine, so a matrix failure names which -// one broke without the reader having to match it to a job title. +// message this smoke prints carries the engine, so a failure names which one +// broke rather than leaving the reader to remember what they invoked it with. let BROWSER; try { BROWSER = resolveBrowserName(); diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 987bfe3d5a..65cc2f5291 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -46,10 +46,9 @@ * Launching the browser (and resolving Playwright from clients/web, which has * its own gotcha — see `lib/headless-browser.mjs`) is delegated to that module, * which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the - * default), `firefox`, or `webkit` (#2086). CI gates Chromium (in the `build` - * job's `npm run smoke`) and Firefox (in the `Sandbox smokes` matrix job); - * WebKit runs but is not gated — note this smoke PASSES in WebKit, it is the two - * App smokes that do not (see their headers). + * default), `firefox`, or `webkit` (#2086). **CI runs Chromium only**; the other + * engines are an on-demand tool rather than a gate. This smoke passes in all + * three — it is the two App smokes that fail under WebKit (see their headers). * * The engine question here is narrower than in the App smokes — this asserts a clean * first paint, i.e. that the shipped bundle's syntax and API level are @@ -73,8 +72,8 @@ import { const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); // Resolved before the web server is started, so an unsupported SMOKE_BROWSER -// fails immediately. Every message carries the engine, so a matrix failure names -// which one broke. +// fails immediately. Every message carries the engine, so a failure names which +// one broke. let BROWSER; try { BROWSER = resolveBrowserName(); diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 9e82801aa4..4c485ee035 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -25,14 +25,15 @@ * proof to a PR); unset, it asserts only. * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`). CI gates **Chromium** (in the `build` job's `npm run smoke`) and - * **Firefox** (in the `Sandbox smokes` matrix job); WebKit runs here but is not - * gated: this smoke fails under Playwright's WebKit for reasons nobody has - * identified, and nobody is currently investigating. It does NOT reproduce in - * real Safari, and an isolated SSE repro did not reproduce it in Playwright's - * WebKit either — so it is a property of that build, not a browser bug, and it - * was judged not worth chasing. Run WebKit locally if you want the signal; do - * not read a failure here as a defect until someone has looked (#2086). + * `webkit`). **CI runs Chromium only.** The other engines are an on-demand tool, + * not a gate: `SMOKE_BROWSER=firefox npm run smoke:web:engine` before touching + * the sandbox is cheap and worth doing, but nothing runs it for you (#2086). + * + * Firefox passes. WebKit fails this smoke for reasons nobody has identified and + * nobody is investigating: it does not reproduce in real Safari, and an isolated + * SSE repro did not reproduce it under Playwright's WebKit either, so it reads + * as a property of that build rather than a browser bug. Do not read a WebKit + * failure here as a defect until someone has actually looked. * * Along with `smoke:web:app` this is one * of the two places the MCP Apps sandbox is actually loaded, and the two nested @@ -65,7 +66,7 @@ const repoRoot = resolve(import.meta.dirname, ".."); // Resolved before anything is started, so an unsupported SMOKE_BROWSER fails // immediately rather than after a web server and two MCP servers are up. Every -// message carries the engine, so a matrix failure names which one broke. +// message carries the engine, so a failure names which one broke. let BROWSER; try { BROWSER = resolveBrowserName(); From 7501dad6755653a0799c7c891e15768008ba8cb0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 00:12:55 -0400 Subject: [PATCH 010/213] ci: put the Firefox smokes in the pre-push gate instead of GitHub CI Dropping the CI job left cross-engine checking as something you had to remember to run, which in practice means nobody runs it. This moves it into npm run ci, so it happens once per push, in front of a human who can still reason about the change -- rather than once per CI run, on every branch, to re-derive an answer we already had. scripts/run-engine-smokes.mjs is what makes that possible cross-platform: SMOKE_BROWSER has to reach three child processes, and a POSIX VAR=x npm run prefix does not work under Windows cmd.exe, which npm uses there. Setting it in the child env from Node does. smoke:web:firefox passes the engine as an ARGUMENT rather than relying on the ambient variable, so a stray SMOKE_BROWSER cannot silently redirect the gate to another engine -- verified: an explicit argument beats it. It also gives the smoke list one home. It was previously an && chain in package.json beside a second chain in the workflow, so adding a fourth smoke meant remembering both. ENGINE_SMOKES is now the list, with a test pinning its contents and its order -- a smoke going missing is invisible at runtime, because a shorter list is still a passing run. Firefox only. WebKit fails the App smokes for unidentified reasons and must not be in a gate. This makes npm run ci a strict superset of GitHub CI rather than a mirror. That was already true of smoke:tui, which self-skips on CI; Firefox is the second such step. The direction that matters still holds: passing npm run ci locally means CI's gates will pass. Signed-off-by: cliffhall --- .github/copilot-instructions.md | 4 +- AGENTS.md | 19 +++++-- README.md | 8 +-- package.json | 5 +- scripts/run-engine-smokes.mjs | 83 ++++++++++++++++++++++++++++++ scripts/run-engine-smokes.test.mjs | 45 ++++++++++++++++ 6 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 scripts/run-engine-smokes.mjs create mode 100644 scripts/run-engine-smokes.test.mjs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 34855be7cf..de5ff9f427 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -102,10 +102,10 @@ Both exist and do different jobs. Theme files (`src/theme/.ts`) custo ## Gates and PR hygiene -- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `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. +- `npm run format` before committing; **`npm run ci` before pushing** (`validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → Storybook; the Firefox step is local-only and not mirrored in GitHub CI). `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`; `pack:verify`'s `--web` child sets its own `MCP_CATALOG_PATH` for the same reason (#2003 — its App deep link persists a server row). Anything that boots the web backend and then *navigates* it needs that isolation, not just the scripts named `smoke:*`. 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. -- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit). **CI runs Chromium only**; the others are an on-demand tool, deliberately not a gate (#2086 — a Firefox job was trialled and removed for never disagreeing with Chromium). They exist because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. +- **The headless web smokes are engine-parameterized, not Chromium-only.** `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit` take their engine from `SMOKE_BROWSER` (chromium — the default — firefox, webkit). **GitHub CI runs Chromium only; Firefox runs in the local pre-push gate** via `smoke:web:firefox` (#2086 — a CI job was trialled and removed for never disagreeing with Chromium across a dozen runs, and moved into `npm run ci` instead). They exist because the MCP Apps sandbox is built out of the primitives that diverge between engines: `srcdoc` CSP inheritance, nested sandboxed iframes, `Permissions-Policy`, cross-frame `postMessage`. Nothing else covers that — `sandbox-csp.test.ts` asserts a policy *string*, and no Storybook story reaches the sandbox at all. Flag a new browser-driven script that launches Playwright itself instead of going through `scripts/lib/headless-browser.mjs`, an unrecognized-engine path that falls back to Chromium rather than failing (it would claim coverage that never ran), or a launch-failure message naming the wrong engine. `pack:verify` is pinned to Chromium on purpose — it is a packaging check. - **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_. - **Lint has no warning tier.** Every `lint` script runs `--max-warnings 0`, so a warning fails `validate` exactly as an error does (#2085) — a `warn`-level `react-hooks/exhaustive-deps` finding otherwise let a stale-closure bug pass the pre-push gate and reach review. Flag a PR that silences a finding to make the gate pass (widening a `globalIgnores`, dropping a rule, or an inline disable with no justification comment); the fix is the defect, not the message. A rule meant to be enforced should be set to `error` rather than left at `warn` and carried by the flag. diff --git a/AGENTS.md b/AGENTS.md index 547063145f..f1e799344c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -886,7 +886,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) → `verify:bundle-externals` (the #2067 must-not-bundle 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). +- **`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) → `verify:bundle-externals` (the #2067 must-not-bundle gate) → `smoke` → **`smoke:web:firefox`** (the three browser-driven smokes again under Firefox — #2086; see below) → Storybook play-function tests (installs Playwright chromium if needed). Note `smoke:web:firefox` is the one step that is **not** in GitHub CI: it is a superset, not a mirror, and deliberately so. 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 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`. @@ -895,7 +895,7 @@ The ⚠️ option-deletion hazard, the snapshot rule, and the recovery recipe ab - **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 verify:bundle-externals`** runs after `verify:build-gate` in `npm run ci`. It asserts that no package a client declares `external` was inlined into that client's bundle anyway — the must-not-bundle invariant above (#2067). It reads `clients/*/build`, so it needs a build to have run (`validate` provides one). - **`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` / `smoke:web:elicit`, 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 `verify:bundle-externals` (the #2067 must-not-bundle gate), 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`, as is `smoke:web:firefox` (the same browser-driven smokes again under Firefox, #2086 — CI does not run that step). It runs `smoke:launcher` (`--help` dispatch) plus the prod `smoke:cli` / `smoke:tui` / `smoke:web` / `smoke:web:browser` / `smoke:web:app` / `smoke:web:elicit`, 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 `verify:bundle-externals` (the #2067 must-not-bundle gate), 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`, `smoke:web:app` and `smoke:web:elicit`**, so the four can't drift. @@ -908,9 +908,17 @@ 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 ` 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. -### The web smokes are engine-parameterized; CI runs Chromium only (#2086) +### The web smokes are engine-parameterized; Firefox is in the pre-push gate, not CI (#2086) -**`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** **CI runs Chromium only** — the other engines are an on-demand tool, not a gate (see the CI bullet for why). `npm run smoke:web:engine` runs all three smokes in whichever engine is selected — that script, not the workflow YAML, is the list of engine-covered smokes, so adding a fourth covers every engine without touching CI. +**`smoke:web:browser`, `smoke:web:app` and `smoke:web:elicit` support Chromium, Firefox and WebKit; `SMOKE_BROWSER` picks one, unset means `chromium`.** + +```bash +npm run smoke:web:firefox # part of `npm run ci` +SMOKE_BROWSER=webkit npm run smoke:web:engine # any engine, on demand +SMOKE_BROWSER=firefox npm run smoke:web:app # one smoke, one engine +``` + +**Firefox runs in the local pre-push gate (`npm run ci`) and NOT in GitHub CI.** That split is the whole design — see the gate bullet below. `ENGINE_SMOKES` in `scripts/run-engine-smokes.mjs` is the single list of which smokes are engine-sensitive; add a fourth there and every engine picks it up. `smoke:web:firefox` passes the engine as an argument rather than relying on the ambient `SMOKE_BROWSER`, so the gate cannot be silently redirected to another engine by a stray variable — verified, an explicit argument beats it. Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** — `resolveBrowserName`, `loadBrowser`, and the `attachPageDiagnostics` / `FATAL_CONSOLE` split the smokes had each hand-rolled. Reach for it rather than launching Playwright in a new script. @@ -918,7 +926,8 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **No other tier can substitute, so don't propose one.** `sandbox-csp.test.ts` asserts which policy _string_ is built — environment-independent by construction, and it would pass identically on an engine that ignores `` CSP entirely. And **no Storybook story reaches the sandbox at all**: all three App stories (`AppRenderer`, `AppsScreen`, `AppElicitationHost`) point the iframe at a `data:` placeholder and hand the renderer a mock bridge, so `sandbox-csp.ts` is imported by exactly two things in the tree — its own test and `createAppBridgeFactory.ts`. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. - **An unrecognized `SMOKE_BROWSER` is an error, never a fallback.** Falling back to Chromium would report a green Chromium run under a job labelled `webkit` — coverage claimed but not run, which is worse than none. - **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. -- **CI is Chromium-only, deliberately, and a Firefox job was trialled and removed.** The job was cheap — ~2 minutes, parallel with the 15-minute `build` job, so zero added wall-clock — and it still did not earn its place: across a dozen runs Firefox never once disagreed with Chromium, so it spent runner time and carried a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result already in hand. **Don't re-add it on the argument that cross-engine coverage is good in principle** — that argument was already accepted, and it is what the on-demand path serves. Re-add it on evidence: an actual cross-engine regression that a gate would have caught. It is a one-job diff when that happens. +- **Firefox is gated locally, not in CI, and that asymmetry is deliberate.** A GitHub Actions job was trialled and removed. It was cheap — ~2 minutes, parallel with the 15-minute `build` job, so zero added wall-clock — but across a dozen runs Firefox never once disagreed with Chromium, so it spent runner minutes on every push, from every branch, carrying a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result already in hand. Moving it into `npm run ci` keeps the check where a human is about to push a change they can still reason about, and pays for it once rather than on every push. **Don't re-add the CI job on the argument that cross-engine coverage is good in principle** — that argument was accepted, and the pre-push gate is what serves it. Re-add it on evidence: a cross-engine regression that reached `v2/main` because someone skipped the gate. +- **This makes `npm run ci` a strict superset of GitHub CI rather than a mirror of it.** That was already the direction (`smoke:tui` self-skips on CI and runs only locally); Firefox is the second such step. The invariant that still holds, and the one that matters, is the useful direction: **passing `npm run ci` locally means CI's gates will pass.** - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. - **WebKit fails the two App smokes**, for reasons nobody has identified — the failure stage even varies between runs. The investigation was dropped as not worth the effort once it became clear it does not reproduce in real Safari, and that an isolated repro of the suspected mechanism did not reproduce it under Playwright's WebKit either. Treat a WebKit failure as unexplained rather than as a defect until someone has actually looked. - ⚠️ **A red WebKit run is not a Safari indictment, for the same reason a green one is not a Safari guarantee.** That failure was first written up as a Safari bug on the strength of a Playwright-WebKit run alone; a manual check in Safari did not reproduce it, and neither did an isolated repro of the mechanism it was blamed on. The caveat below cuts both ways, and only one direction of it was applied. **Reproduce in the real browser before claiming user impact in one** — the divergence between Playwright's WebKit build and Safari is largest in exactly the layer (networking) where that bug lives. diff --git a/README.md b/README.md index 092fa83739..d7004aec07 100644 --- a/README.md +++ b/README.md @@ -459,7 +459,7 @@ Each client self-validates from its own folder; the root scripts chain them. The | `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 two suites over shared `scripts/lib` helpers that no smoke can check itself: `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 — and `announced-child.test.mjs` — the spawn/readiness ownership helper (#2000), which drives real `node -e` children to prove a child that never announces is still published to the caller before the timeout throws, and so is reachable by teardown rather than orphaned. Two more do the same: `mcp-app-flow.test.mjs` covers the shared MCP Apps flow (#2003) — the deep link's two CSRF gates and `appArgs` encoding, plus `driveAppFlow`'s failure branches against a stand-in page, all of which are dead code from the happy-path smokes' point of view and would otherwise surface only as opaque timeouts; and `ensure-test-servers.test.mjs` pins the [#2111](https://github.com/modelcontextprotocol/inspector/issues/2111) invariant — that `test-servers/build` is rebuilt **even when it already exists** — which no smoke can assert about itself, since one driving a stale fixture reports a product failure rather than a staleness one. 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` → `verify:bundle-externals` → `smoke` → Storybook. A true superset of GitHub CI. | +| `npm run ci` | **Mandatory pre-push command.** `validate` → `coverage` → `verify:build-gate` → `verify:bundle-externals` → `smoke` → `smoke:web:firefox` → Storybook. A strict superset of GitHub CI — `smoke:web:firefox` (and `smoke:tui`) run only here. | | `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. @@ -479,11 +479,13 @@ SMOKE_BROWSER=webkit npm run smoke:web:app # one smoke, one engine SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine ``` -Unset, the engine is `chromium` — so `npm run ci` and `npm run smoke` are unchanged, and **CI runs Chromium only**. The other engines are a tool you reach for, not a gate: running the sandbox smokes under Firefox before touching the MCP Apps sandbox, the CSP builder, or the proxy page is cheap and worth doing, but nothing runs it for you. An unrecognized value is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. +Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run ci` — the mandatory pre-push gate — additionally runs all three smokes under Firefox** via `smoke:web:firefox`; **GitHub CI does not.** An unrecognized `SMOKE_BROWSER` is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. **Firefox passes all three smokes. WebKit fails the two App smokes**, for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort — treat a WebKit failure as unexplained rather than as a defect until someone has looked. -**Why none of this is gated in CI.** A Firefox job was trialled and was cheap — about two minutes, running in parallel with the 15-minute `build` job, so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it was paying real runner time and a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result we already had. Making it available on demand keeps the value — a cross-engine check right when you are changing engine-sensitive code — without a permanent tax on every push. If a cross-engine regression ever does turn up, that is the evidence for putting the job back; it is a one-job diff. +**Why Firefox is in the pre-push gate rather than in CI.** A GitHub Actions job was trialled and was cheap — about two minutes, in parallel with the 15-minute `build` job, so no added wall-clock. It was dropped anyway, on the honest count: across a dozen runs it never once disagreed with Chromium, so it spent runner minutes on every push from every branch to re-confirm a result already in hand, and carried a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks). + +Putting it in `npm run ci` instead keeps the check where it is worth most — in front of a human about to push a change they can still reason about — and pays for it once per push rather than once per CI run. If a cross-engine regression ever reaches `v2/main` because someone skipped the gate, that is the evidence for restoring the CI job; it is a one-job diff. **Why these smokes specifically.** Most of the web client's behavior is React and Mantine, where a second engine buys little. The MCP Apps sandbox is the exception — it is built out of the primitives that genuinely diverge between engines: a CSP `` injected as the first `` child of a `srcdoc` document, a nested sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. Nothing else covers that: `sandbox-csp.test.ts` asserts which policy _string_ is built, which passes identically on an engine that ignores `` CSP entirely, and no Storybook story reaches the sandbox at all (all three App stories point the iframe at a `data:` placeholder and hand the renderer a mock bridge). Storybook itself remains Chromium-only — broadening it covers a much larger and differently-shaped surface, and is a separate decision. diff --git a/package.json b/package.json index 9337b5a1ed..eba1ca4cb4 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build:tui": "cd clients/tui && npm run build", "build:web": "cd clients/web && npm run build", "build:launcher": "cd clients/launcher && npm run build", - "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run ci:storybook", + "ci": "npm run validate && npm run coverage && npm run verify:build-gate && npm run verify:bundle-externals && npm run smoke && npm run smoke:web:firefox && npm run ci:storybook", "ci:storybook": "cd clients/web && npx playwright install chromium && npm run test:storybook", "verify:build-gate": "node scripts/verify-build-gate.mjs", "verify:bundle-externals": "node scripts/verify-bundle-externals.mjs", @@ -73,7 +73,8 @@ "smoke:web:browser": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-browser.mjs", "smoke:web:app": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-app.mjs", "smoke:web:elicit": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-elicitation.mjs", - "smoke:web:engine": "npm run smoke:web:browser && npm run smoke:web:app && npm run smoke:web:elicit", + "smoke:web:engine": "node scripts/run-engine-smokes.mjs", + "smoke:web:firefox": "node scripts/run-engine-smokes.mjs firefox", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/install-smoke-browser.mjs chromium && node scripts/pack-and-verify.mjs", "prepack": "npm run build", diff --git a/scripts/run-engine-smokes.mjs b/scripts/run-engine-smokes.mjs new file mode 100644 index 0000000000..29c041a703 --- /dev/null +++ b/scripts/run-engine-smokes.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** + * Run the three headless web smokes in one browser engine (#2086). + * + * `SMOKE_BROWSER=firefox npm run smoke:web:engine` needs the variable to reach + * three child processes, and a POSIX `VAR=x npm run …` prefix does not work + * under Windows' `cmd.exe`, which npm uses there. Setting it in the child env + * from Node does, on every platform — the same reason `install-smoke-browser` + * exists rather than an inline shell expansion. + * + * It also gives the smoke list ONE home. It used to be an `&&` chain in + * package.json alongside a second chain in the workflow; adding a fourth smoke + * meant remembering both. Now `ENGINE_SMOKES` is the list, and both the + * on-demand command and the pre-push gate run through it. + * + * Usage: `node scripts/run-engine-smokes.mjs [engine]`. With no argument the + * engine comes from `SMOKE_BROWSER` (default `chromium`). + */ + +import { spawnSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveRequestedBrowser } from "./install-smoke-browser.mjs"; + +/** + * The smokes that are engine-sensitive, in run order. + * + * Cheapest first, so a bundle that cannot even boot on the engine fails in + * seconds rather than after two full App flows have timed out. + */ +export const ENGINE_SMOKES = [ + "smoke-web-browser.mjs", + "smoke-web-app.mjs", + "smoke-web-elicitation.mjs", +]; + +function main() { + const scriptDir = resolve(import.meta.dirname); + + let browserName; + try { + browserName = resolveRequestedBrowser(process.argv.slice(2), process.env); + } catch (err) { + console.error( + `run-engine-smokes: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + // Children inherit this rather than reading the ambient variable, so an + // explicit argument wins over SMOKE_BROWSER all the way down. + const env = { ...process.env, SMOKE_BROWSER: browserName }; + + const install = spawnSync( + process.execPath, + [join(scriptDir, "install-smoke-browser.mjs"), browserName], + { stdio: "inherit", env }, + ); + if (install.status !== 0) process.exit(install.status ?? 1); + + for (const smoke of ENGINE_SMOKES) { + const result = spawnSync(process.execPath, [join(scriptDir, smoke)], { + stdio: "inherit", + env, + }); + if (result.error) { + console.error( + `run-engine-smokes: could not run ${smoke}: ${result.error.message}`, + ); + process.exit(1); + } + if (result.status !== 0) { + // The smoke has already printed its own diagnosis; add only the engine, + // so a failure in the pre-push gate names which run it came from. + console.error(`run-engine-smokes: ${smoke} failed under ${browserName}`); + process.exit(result.status ?? 1); + } + } +} + +// Only when run as a script, never on import — see install-smoke-browser.mjs. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + main(); diff --git a/scripts/run-engine-smokes.test.mjs b/scripts/run-engine-smokes.test.mjs new file mode 100644 index 0000000000..ca8516c6c3 --- /dev/null +++ b/scripts/run-engine-smokes.test.mjs @@ -0,0 +1,45 @@ +/** + * The engine-smoke runner's list (#2086). + * + * `ENGINE_SMOKES` is the single home for "which smokes are engine-sensitive", + * and the thing that can silently rot about it is a smoke going missing — the + * suite still passes, just covering less. Nothing at runtime notices, because a + * shorter list is a *successful* run. + * + * Importing the module must not spawn anything; `main()` is behind an entrypoint + * guard, and this test would catch that regressing by running three browser + * smokes the moment the suite imported it. + */ + +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { ENGINE_SMOKES } from "./run-engine-smokes.mjs"; + +const scriptDir = import.meta.dirname; + +describe("ENGINE_SMOKES", () => { + it("names every browser-driven smoke, and each one exists", () => { + // Deliberately spelled out rather than derived: this list IS the coverage + // claim, so a test that computed it from disk would agree with any mistake. + assert.deepEqual(ENGINE_SMOKES, [ + "smoke-web-browser.mjs", + "smoke-web-app.mjs", + "smoke-web-elicitation.mjs", + ]); + for (const smoke of ENGINE_SMOKES) { + assert.ok( + existsSync(join(scriptDir, smoke)), + `${smoke} is listed but missing — a rename would silently shrink the run`, + ); + } + }); + + it("runs the cheapest smoke first", () => { + // smoke-web-browser only boots the bundle; the other two drive a whole App + // flow with 45s waits. Ordering it first is what makes "this engine cannot + // run the bundle at all" fail in seconds rather than after two timeouts. + assert.equal(ENGINE_SMOKES[0], "smoke-web-browser.mjs"); + }); +}); From 5933162dd0774f0fd067bc8f5aba1ca3ff2f0df9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 00:37:43 -0400 Subject: [PATCH 011/213] fix: render tool schemas with root-level anyOf/oneOf composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool's `inputSchema` may carry composition keywords at its root — the 2026-07-28 revision makes this explicit — but both form builders enumerate the root's `properties` and nothing else. A schema keeping its fields on `allOf`/`oneOf`/`anyOf` branches therefore rendered no controls at all: no branch picker, no fields, not even the raw-JSON fallback a union-typed *property* falls back to, so the tool could only be called with empty arguments. Adds `core/json/rootUnion.ts`, one flattening shared by all three clients so they cannot drift on which schemas they can render: - `allOf` is merged unconditionally (conjunctive — there is no choice to present), a root `oneOf`/`anyOf` is returned as branches. - Web `SchemaForm` renders a Variant picker above the fields and prunes the outgoing branch's values on a switch, since they are no longer visible and would describe a shape the call is not making. - TUI `schemaToForm` gives each branch its own section with optional fields — ink-form is static, and only one alternative applies to a call. - `convertToolParameters` looks through the branches, so a CLI `--tool-arg` is typed by the schema that declares it rather than sent as a string. Required-field gating asks whether *any* branch is satisfied, which is selection-independent and never blocks arguments the schema accepts. A `const` is seeded like a `default` and rendered read-only: it admits exactly one value, so anything else the user could type produces a rejected call. Adds a `root-union-schemas-http.json` showcase server. Closes #2123 Signed-off-by: cliffhall --- AGENTS.md | 19 ++ README.md | 22 +- clients/tui/__tests__/schemaToForm.test.ts | 85 +++++++ clients/tui/src/utils/schemaToForm.ts | 77 ++++-- .../groups/SchemaForm/SchemaForm.stories.tsx | 38 +++ .../groups/SchemaForm/SchemaForm.test.tsx | 162 ++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 103 +++++++- clients/web/src/test/core/jsonUtils.test.ts | 44 ++++ clients/web/src/test/core/rootUnion.test.ts | 235 +++++++++++++++++ clients/web/src/utils/jsonUtils.test.ts | 93 +++++++ clients/web/src/utils/jsonUtils.ts | 38 ++- clients/web/src/utils/toolUtils.test.ts | 36 +++ clients/web/src/utils/toolUtils.ts | 16 +- core/json/jsonUtils.ts | 17 +- core/json/rootUnion.ts | 236 ++++++++++++++++++ .../configs/root-union-schemas-http.json | 67 +++++ 16 files changed, 1259 insertions(+), 29 deletions(-) create mode 100644 clients/web/src/test/core/rootUnion.test.ts create mode 100644 core/json/rootUnion.ts create mode 100644 test-servers/configs/root-union-schemas-http.json diff --git a/AGENTS.md b/AGENTS.md index 936af5d8cb..e54e134675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,25 @@ v2/main/ │ │ # and TUI schemaToForm — since each dispatches on │ │ # a single `type` string and would otherwise miss │ │ # a nullable field entirely — #1928/#2015; +│ │ # rootUnion.ts: flattens the COMPOSITION keywords a +│ │ # tool's inputSchema may carry at its ROOT — `allOf` +│ │ # merged unconditionally (conjunctive, so there is +│ │ # no choice to present), a root `oneOf`/`anyOf` +│ │ # returned as the BRANCHES a picker chooses between. +│ │ # Legal since 2026-07-28 and rendered as an EMPTY +│ │ # FORM before — no picker, no fields, not even the +│ │ # raw-JSON fallback a union-typed *property* gets, +│ │ # so the tool could only be called with empty +│ │ # arguments. Read by all three clients: web +│ │ # SchemaForm (the Variant picker + the branch-change +│ │ # value pruning), TUI schemaToForm (a section per +│ │ # branch, its fields forced OPTIONAL since only one +│ │ # alternative applies), and convertToolParameters +│ │ # (which branch's schema types a CLI --tool-arg). +│ │ # Declines a union whose members are not ALL +│ │ # field-carrying objects rather than offering a +│ │ # picker with options that render nothing, and does +│ │ # not interpret `not` at all — #2123; │ │ # schemaLint.ts: tool-schema PORTABILITY lint — │ │ # constructs that are legal JSON Schema and are │ │ # refused or mishandled by real MCP clients (a bare diff --git a/README.md b/README.md index 1272b2b0d5..f9a5e5fcc2 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ inspector/ │ │ # 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, the nullable-union -│ │ # schema collapse shared by the web and TUI form builders, and the -│ │ # tool-schema portability lint all three clients report from +│ │ # schema collapse and root-composition flattening shared by the web +│ │ # and TUI form builders, and the tool-schema portability lint all +│ │ # three clients report from │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime, state stores, transports, config import, │ │ # and the RFC 6570 URI-template helpers the web form and TUI expand through @@ -153,6 +154,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) | +| `root-union-schemas-http.json` **(legacy era)** | Tool schemas whose arguments are a root `anyOf` / `oneOf` | [#2123](https://github.com/modelcontextprotocol/inspector/issues/2123) | | `unportable-schemas-http.json` **(legacy era)** | Tool schemas a real client rejects, flagged in all three clients | [#1005](https://github.com/modelcontextprotocol/inspector/issues/1005) | | `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) | @@ -288,6 +290,22 @@ 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. +#### Root-level unions + +`root-union-schemas-http.json` serves two tools whose arguments are declared as a **composition at the root** of `inputSchema` rather than as a flat `properties` map — `echo` with an `anyOf` beside its own `message` property, and `get_weather` with an OpenAPI-style `discriminator` over a `oneOf`. Plain streamable-HTTP — connect with the **default (legacy)** protocol era. + +The 2026-07-28 revision makes this shape explicitly legal: `type: "object"` is required at the root, and beyond that "any JSON Schema 2020-12 keyword may appear alongside `type`, including composition keywords (`oneOf`, `anyOf`, `allOf`, `not`)". + +Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. On the broken build both tools rendered **nothing but the Execute Tool button**: no picker, no fields, not even the raw-JSON editor a union-typed _property_ falls back to, so neither tool could be called with anything but empty arguments ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)). + +Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making. + +The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, and the fields in it are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. Untouched fields report no value and are dropped before the call, so the sections you skip contribute nothing. + +The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. + +Two things it deliberately does **not** do. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — the schema falls back to whatever its root `properties` describe. And `not` is not interpreted at all: there is no faithful form for "anything except this". + #### Unportable tool schemas `unportable-schemas-http.json` serves four tools, three of whose advertised diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 0a2d226420..f1f763c59a 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -408,4 +408,89 @@ describe("schemaToForm", () => { }); }); }); + describe("root composition (#2123)", () => { + const UNION = { + type: "object", + properties: { note: { type: "string" } }, + discriminator: { propertyName: "kind" }, + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + count: { type: "integer" }, + }, + required: ["kind", "count"], + }, + ], + }; + + it("gives each branch its own section instead of rendering no fields", () => { + const form = schemaToForm(UNION, "union_tool"); + expect(form.sections.map((section) => section.title)).toEqual([ + "Parameters", + "email", + "sms", + ]); + expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ + "note", + ]); + expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([ + "kind", + "address", + ]); + }); + + it("keeps a branch's typed fields typed", () => { + const form = schemaToForm(UNION, "union_tool"); + expect(form.sections[2]!.fields[1]).toMatchObject({ + name: "count", + type: "integer", + }); + }); + + it("renders branch fields optional, since only one branch applies", () => { + const form = schemaToForm(UNION, "union_tool"); + for (const field of form.sections[1]!.fields) { + expect(field.required).toBe(false); + } + }); + + it("seeds a branch's discriminator const so it need not be typed", () => { + const form = schemaToForm(UNION, "union_tool"); + expect(form.sections[1]!.fields[0]).toMatchObject({ + name: "kind", + initialValue: "email", + }); + }); + + it("merges a root allOf into the parameters section", () => { + const form = schemaToForm( + { + type: "object", + properties: { a: { type: "string" } }, + allOf: [{ type: "object", properties: { b: { type: "boolean" } } }], + }, + "allof_tool", + ); + expect(form.sections).toHaveLength(1); + expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ + "a", + "b", + ]); + }); + + it("still renders an empty form for a schema with no properties", () => { + const form = schemaToForm({ type: "object" }, "empty_tool"); + expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]); + }); + }); }); diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 6332450c99..321b10ecf4 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -7,6 +7,7 @@ import { isStringEnum, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; +import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; /** Minimal JSON Schema property shape used when building tool parameter forms */ interface JsonSchemaProperty { @@ -24,6 +25,8 @@ interface JsonSchemaProperty { minimum?: number; maximum?: number; default?: unknown; + /** A one-value enumeration; seeded like a `default` — see below. */ + const?: unknown; /** Present on a nullable union; see {@link normalizeNullableUnion}. */ anyOf?: readonly unknown[]; } @@ -54,6 +57,17 @@ function toSelectOptions( interface JsonSchemaObject { properties?: Record; required?: string[]; + /** + * Root composition, read by {@link resolveRootUnion} before `properties` is + * enumerated (#2123). Members are `unknown` for the same reason property + * values are: the SDK's `Tool["inputSchema"]` types them as the recursive + * JSON type, and each is narrowed where it is used. + */ + type?: string | string[]; + allOf?: readonly unknown[]; + anyOf?: readonly unknown[]; + oneOf?: readonly unknown[]; + discriminator?: { propertyName?: string }; } /** @@ -63,15 +77,44 @@ export function schemaToForm( schema: JsonSchemaObject | null | undefined, toolName: string, ): FormStructure { - const fields: FormField[] = []; + const title = `Test Tool: ${toolName}`; + if (!schema) { + return { title, sections: [{ title: "Parameters", fields: [] }] }; + } - if (!schema || !schema.properties) { - return { - title: `Test Tool: ${toolName}`, - sections: [{ title: "Parameters", fields: [] }], - }; + // Flatten root composition before reading `properties` (#2123). Without it a + // tool whose arguments are declared as a root `oneOf`/`anyOf` — legal since + // the 2026-07-28 revision — rendered a form with no fields at all, so it + // could only be called with empty arguments. + const { base, branches } = resolveRootUnion(schema); + + const sections: FormSection[] = [ + { title: "Parameters", fields: buildFields(base) }, + ]; + + // ink-form is static — there is no branch picker to hide the alternatives + // behind — so every branch gets its own section and the user fills the one + // they mean. A branch's fields are rendered **optional** whatever the branch + // says: only one alternative applies to a given call, so requiring them would + // make a form that can never be submitted. An untouched field reports no + // value and is dropped before the call, so the sections the user skipped + // contribute nothing to the arguments. + for (const branch of branches) { + const ownProperties = Object.fromEntries( + branch.ownFields.map((name) => [name, branch.schema.properties?.[name]]), + ); + sections.push({ + title: branch.label, + fields: buildFields({ properties: ownProperties }), + }); } + return { title, sections }; +} + +/** Build the ink-form fields for one already-flattened object schema. */ +function buildFields(schema: JsonSchemaObject): FormField[] { + const fields: FormField[] = []; const properties = schema.properties || {}; const required = schema.required || []; @@ -160,24 +203,18 @@ export function schemaToForm( } } - // Set initial value from default (ink-form FormField allows initialValue for some types) - if (property.default !== undefined) { + // Set initial value from default (ink-form FormField allows initialValue for some types). + // A `const` is seeded the same way: it is a one-value enumeration, so the + // only submittable value is already known and the user would otherwise have + // to hand-type a union's discriminator (#2123). + const initialValue = property.default ?? property.const; + if (initialValue !== undefined) { (field as FormField & { initialValue?: unknown }).initialValue = - property.default; + initialValue; } fields.push(field); } - const sections: FormSection[] = [ - { - title: "Parameters", - fields, - }, - ]; - - return { - title: `Test Tool: ${toolName}`, - sections, - }; + return fields; } diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx index b98a4b43fb..e5fbf760fd 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.stories.tsx @@ -180,3 +180,41 @@ export const Disabled: Story = { disabled: true, }, }; + +/** + * Arguments declared as a composition at the root of the schema (#2123): a + * picker chooses the alternative, and its fields render beneath. Before this, + * such a schema produced a form with no controls at all. + */ +export const RootUnion: Story = { + args: { + schema: { + type: "object", + properties: { + message: { type: "string", title: "Message" }, + }, + required: ["message"], + anyOf: [ + { + type: "object", + title: "By email", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string", title: "Address" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + title: "By SMS", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string", title: "Phone" }, + }, + required: ["kind", "phone"], + }, + ], + }, + values: { kind: "email" }, + }, +}; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index d0ec70a861..4ba2cf2e84 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -1969,4 +1969,166 @@ describe("SchemaForm multiline strings (#2042)", () => { ); expect(screen.getByRole("button", { name: "Enlarge Note" })).toBeDisabled(); }); + describe("a root-level union (#2123)", () => { + const UNION_SCHEMA: InspectorFormSchema = { + type: "object", + properties: { note: { type: "string", title: "Note" } }, + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string", title: "Address" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string", title: "Phone" }, + }, + required: ["kind", "phone"], + }, + ], + }; + + it("renders the first branch's fields with a picker, not an empty form", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /Note/ })).toBeTruthy(); + expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull(); + expect( + (screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement) + .value, + ).toBe("email"); + }); + + it("switches to the chosen branch's fields", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "sms" })); + expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: /Address/ })).toBeNull(); + }); + + it("drops the outgoing branch's values and seeds the incoming branch's const", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "sms" })); + // `address` belongs to a shape this call is no longer making, so it must + // not ride along invisibly into the submitted arguments. + expect(onChange).toHaveBeenCalledWith({ note: "hi", kind: "sms" }); + }); + + it("renders a const-pinned field read-only", () => { + renderWithMantine( + , + ); + const kind = screen.getByRole("textbox", { + name: /kind/, + }) as HTMLInputElement; + expect(kind.readOnly).toBe(true); + expect(kind.value).toBe("email"); + }); + + it("renders no picker for a single-branch union but still shows its fields", () => { + const schema: InspectorFormSchema = { + type: "object", + anyOf: [ + { + type: "object", + properties: { only: { type: "string", title: "Only" } }, + }, + ], + }; + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /Only/ })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: /Variant/ })).toBeNull(); + }); + + it("renders root allOf fields", () => { + const schema: InspectorFormSchema = { + type: "object", + allOf: [ + { + type: "object", + properties: { merged: { type: "string", title: "Merged" } }, + }, + ], + }; + renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /Merged/ })).toBeTruthy(); + }); + + it("returns to the first branch when resetKey says the form moved on", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "sms" })); + expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy(); + + rerender( + , + ); + expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy(); + expect(screen.queryByRole("textbox", { name: /Phone/ })).toBeNull(); + }); + + it("clamps a selection the next schema's shorter union cannot hold", async () => { + const user = userEvent.setup(); + const { rerender } = renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "sms" })); + + // No `resetKey`, so nothing resets the selection: index 1 must be clamped + // rather than reaching past the end of a one-branch union. + const shorter: InspectorFormSchema = { + type: "object", + anyOf: [ + { + type: "object", + properties: { solo: { type: "string", title: "Solo" } }, + }, + ], + }; + rerender(); + expect(screen.getByRole("textbox", { name: /Solo/ })).toBeTruthy(); + }); + }); }); diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 7b39c5dd1f..495779bc55 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -31,6 +31,8 @@ import { isStringEnum, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; +import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; +import { collectSchemaDefaults } from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ fw: 500, @@ -45,6 +47,14 @@ const FieldDescription = Text.withProps({ // Indented column for a nested object's sub-fields. const IndentedStack = Stack.withProps({ gap: "sm", pl: "md" }); +// The picker for a root `oneOf`/`anyOf` (#2123). Not clearable: one branch is +// always in effect, so "no branch" is not a state the arguments can be in. +const BranchSelect = Select.withProps({ + label: "Variant", + description: "This tool accepts one of several argument shapes.", + allowDeselect: false, +}); + const SchemaJsonInput = JsonInput.withProps({ formatOnBlur: true, autosize: true, @@ -569,8 +579,27 @@ export function SchemaForm({ resetKey, onValidityChange, }: SchemaFormProps) { - const properties = schema.properties ?? {}; - const requiredFields = schema.required ?? []; + // Composition at the root of the schema, flattened before anything is + // rendered (#2123). `allOf` is folded into `base`; a top-level `oneOf`/`anyOf` + // becomes the branches a picker chooses between. Both are empty for the + // ordinary object schema, where `base` is the schema itself. + const { base, branches } = resolveRootUnion(schema); + + // Which alternative the form is currently showing. Held here because it is a + // property of this rendering, not of the arguments: `values` carries what the + // user typed, and nothing in it names a branch. + const [branchIndex, setBranchIndex] = useState(0); + // A form reused for another entity can be handed a shorter union, so the + // index is clamped rather than trusted — `resetKey` resets it below, but a + // caller that omits it (the elicitation panels mount fresh) supplies none. + const activeBranch = + branches.length > 0 + ? (branches[Math.min(branchIndex, branches.length - 1)] ?? null) + : null; + const effectiveSchema = activeBranch?.schema ?? base; + + const properties = effectiveSchema.properties ?? {}; + const requiredFields = effectiveSchema.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 @@ -590,7 +619,12 @@ export function SchemaForm({ // *name*, so it must not carry across to another entity's same-named field — // the same reasoning `resetKey` documents for the number field's draft. Reset // during render rather than in an effect so no frame paints the wrong shape. - useValueChange(resetKey, () => setEnlargedFields(new Set())); + useValueChange(resetKey, () => { + setEnlargedFields(new Set()); + // Which branch is selected belongs to the entity it was chosen for, for the + // same reason enlargement does. + setBranchIndex(0); + }); // Stable so a field's reporting effect subscribes once, not per render. The // updater returns the previous set unchanged when nothing moved, which is @@ -637,6 +671,35 @@ export function SchemaForm({ onChange({ ...values, [fieldName]: fieldValue }); } + /** + * Switch branches, and move `values` with the form. + * + * The fields the outgoing branch owned are dropped rather than left behind: + * they are no longer rendered, so the user cannot see or clear them, and + * submitting them would send the server arguments belonging to a shape the + * call is not making. Whatever the base contributes is kept — it applies to + * every branch — and the incoming branch's defaults (a discriminator `const` + * among them) are seeded the way the initial ones were. + */ + function handleBranchChange(nextIndex: number) { + const nextBranch = branches[nextIndex]; + /* v8 ignore next -- the Select's options are built from `branches` */ + if (!nextBranch) return; + setBranchIndex(nextIndex); + const nextProperties = nextBranch.schema.properties ?? {}; + const carried: Record = {}; + for (const [name, fieldSchema] of Object.entries(nextProperties)) { + // A field the incoming branch pins to a `const` is not carried: the two + // branches of a discriminated union share the discriminator's *name* and + // disagree about its value, so keeping what the outgoing branch put + // there would leave the arguments claiming the shape they no longer have. + if (values[name] !== undefined && fieldSchema.const === undefined) { + carried[name] = values[name]; + } + } + onChange({ ...collectSchemaDefaults(nextBranch.schema), ...carried }); + } + 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 @@ -687,6 +750,26 @@ export function SchemaForm({ ); } + // A string pinned to a single value. Rendered read-only rather than as an + // editable box: `const` admits exactly one value, so anything the user + // could type into it produces a call the schema rejects. This is what a + // discriminated union's `kind`/`by` field is (#2123), and the picker has + // already set it — but the rule is the keyword's, not the union's, so it + // holds for a lone `const` property too. + if (typeof fieldSchema.const === "string") { + return ( + + ); + } + // plain string if (fieldSchema.type === "string") { const clearButton = rawValue ? ( @@ -878,6 +961,20 @@ export function SchemaForm({ return ( + {activeBranch && branches.length > 1 && ( + ({ + value: String(index), + label: branch.label, + }))} + value={String(branches.indexOf(activeBranch))} + disabled={disabled} + onChange={(value) => + /* v8 ignore next -- Select only ever reports one of its own options */ + value === null ? undefined : handleBranchChange(Number(value)) + } + /> + )} {Object.entries(properties).map(([fieldName, fieldSchema]) => renderField(fieldName, fieldSchema), )} diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index df422cadaf..6ae1e151d2 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -75,6 +75,50 @@ describe("JSON Utils", () => { }, }; + it("coerces a value whose schema lives on a root union branch (#2123)", () => { + const unionTool: Tool = { + name: "union-tool", + inputSchema: { + type: "object", + properties: { note: { type: "string" } }, + anyOf: [ + { + type: "object", + properties: { count: { type: "number" } }, + }, + { + type: "object", + properties: { enabled: { type: "boolean" } }, + }, + ], + }, + }; + // Reading the root's `properties` alone finds no schema for either, so + // both would have been sent as the strings the user typed. + expect( + convertToolParameters(unionTool, { + note: "hi", + count: "42", + enabled: "true", + }), + ).toEqual({ note: "hi", count: 42, enabled: true }); + }); + + it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { + const allOfTool: Tool = { + name: "allof-tool", + inputSchema: { + type: "object", + allOf: [ + { type: "object", properties: { count: { type: "number" } } }, + ], + }, + }; + expect(convertToolParameters(allOfTool, { count: "7" })).toEqual({ + count: 7, + }); + }); + it("should convert string parameters", () => { const result = convertToolParameters(tool, { message: "hello", diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts new file mode 100644 index 0000000000..101b211e4d --- /dev/null +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect } from "vitest"; +import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; + +const EMAIL = { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], +}; + +const SMS = { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + required: ["kind", "phone"], +}; + +describe("resolveRootUnion", () => { + it("leaves an ordinary object schema alone", () => { + const schema = { + type: "object" as const, + properties: { message: { type: "string" as const } }, + required: ["message"], + }; + const { base, branches } = resolveRootUnion(schema); + expect(branches).toEqual([]); + expect(base.properties).toEqual(schema.properties); + expect(base.required).toEqual(["message"]); + }); + + it("returns a branch per anyOf member, merged with the root", () => { + const { base, branches } = resolveRootUnion({ + type: "object", + properties: { note: { type: "string" } }, + required: ["note"], + anyOf: [EMAIL, SMS], + }); + + // The root's own fields render for every branch, so they stay on the base + // and are merged into each branch rather than belonging to one. + expect(Object.keys(base.properties ?? {})).toEqual(["note"]); + expect(branches).toHaveLength(2); + expect(Object.keys(branches[0].schema.properties ?? {})).toEqual([ + "note", + "kind", + "address", + ]); + expect(branches[0].schema.required).toEqual(["note", "kind", "address"]); + expect(branches[0].ownFields).toEqual(["kind", "address"]); + expect(branches[1].ownFields).toEqual(["kind", "phone"]); + }); + + it("strips the composition keywords it has absorbed", () => { + const { base, branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, SMS], + }); + expect(base.anyOf).toBeUndefined(); + expect(branches[0].schema.anyOf).toBeUndefined(); + }); + + it("prefers oneOf when a schema carries both", () => { + const { branches } = resolveRootUnion({ + type: "object", + oneOf: [EMAIL], + anyOf: [EMAIL, SMS], + }); + expect(branches).toHaveLength(1); + }); + + it("merges allOf branches unconditionally", () => { + const { base, branches } = resolveRootUnion({ + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + allOf: [ + { + type: "object", + properties: { b: { type: "number" } }, + required: ["b"], + }, + ], + }); + expect(branches).toEqual([]); + expect(Object.keys(base.properties ?? {})).toEqual(["a", "b"]); + expect(base.required).toEqual(["a", "b"]); + expect(base.allOf).toBeUndefined(); + }); + + it("merges allOf into every union branch", () => { + const { branches } = resolveRootUnion({ + type: "object", + allOf: [{ type: "object", properties: { shared: { type: "string" } } }], + oneOf: [EMAIL, SMS], + }); + expect(Object.keys(branches[0].schema.properties ?? {})).toContain( + "shared", + ); + }); + + it("lets a branch's declaration win a name collision with the root", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { id: { type: "string" } }, + required: ["id"], + anyOf: [{ type: "object", properties: { id: { type: "number" } } }, SMS], + }); + expect(branches[0].schema.properties?.id).toEqual({ type: "number" }); + // `required` unions rather than duplicating. + expect(branches[0].schema.required).toEqual(["id"]); + }); + + describe("branch labels", () => { + it("uses the branch's own title first", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [{ ...EMAIL, title: "By email" }, SMS], + }); + expect(branches[0].label).toBe("By email"); + }); + + it("uses the discriminator property's const when one is named", () => { + const { branches } = resolveRootUnion({ + type: "object", + discriminator: { propertyName: "kind" }, + oneOf: [EMAIL, SMS], + }); + expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]); + }); + + it("uses a lone constant-valued property when there is no discriminator", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, SMS], + }); + expect(branches.map((branch) => branch.label)).toEqual(["email", "sms"]); + }); + + it("falls back to a position when a branch has several constants", () => { + const twoConstants = { + type: "object", + properties: { + kind: { const: "a" }, + other: { const: "b" }, + }, + }; + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [twoConstants, SMS], + }); + expect(branches[0].label).toBe("Option 1"); + }); + + it("falls back to a position when a branch names no constant", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }); + expect(branches.map((branch) => branch.label)).toEqual([ + "Option 1", + "Option 2", + ]); + }); + + it("ignores a discriminator naming a property without a usable const", () => { + const { branches } = resolveRootUnion({ + type: "object", + discriminator: { propertyName: "missing" }, + anyOf: [EMAIL, SMS], + }); + expect(branches[0].label).toBe("email"); + }); + + it("labels a numeric const by its value", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { type: "object", properties: { v: { const: 1 } } }, + { type: "object", properties: { v: { const: 2 } } }, + ], + }); + expect(branches.map((branch) => branch.label)).toEqual(["1", "2"]); + }); + + it("ignores a blank title", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [{ ...EMAIL, title: " " }, SMS], + }); + expect(branches[0].label).toBe("email"); + }); + }); + + describe("unions it declines to offer", () => { + // Each of these would produce a picker with an option that renders + // nothing, which is the failure this module exists to prevent. + it("declines a union with a member that is not an object", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, "nope" as unknown], + }); + expect(branches).toEqual([]); + }); + + it("declines a union with a fieldless member", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, { type: "null" }], + }); + expect(branches).toEqual([]); + }); + + it("declines an empty union", () => { + expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual( + [], + ); + }); + + it("ignores an allOf member that is not an object", () => { + const { base } = resolveRootUnion({ + type: "object", + properties: { a: { type: "string" } }, + allOf: [null as unknown, 3 as unknown], + }); + expect(Object.keys(base.properties ?? {})).toEqual(["a"]); + }); + }); +}); diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 8f61841cd1..7c84f1e7dc 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -340,3 +340,96 @@ describe("hasMissingRequiredFields", () => { expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true); }); }); + +describe("root composition (#2123)", () => { + const UNION: InspectorFormSchema = { + type: "object", + properties: { note: { type: "string" } }, + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + required: ["kind", "phone"], + }, + ], + }; + + it("seeds the first branch's defaults, including its const", () => { + expect(collectSchemaDefaults(UNION)).toEqual({ kind: "email" }); + }); + + it("does not seed fields of branches the form is not showing", () => { + expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone"); + }); + + it("seeds a const property on an ordinary schema too", () => { + expect( + collectSchemaDefaults({ + type: "object", + properties: { version: { type: "string", const: "1" } }, + }), + ).toEqual({ version: "1" }); + }); + + it("prefers an explicit default over a const", () => { + expect( + collectSchemaDefaults({ + type: "object", + properties: { + v: { type: "string", const: "a", default: "b" }, + }, + }), + ).toEqual({ v: "b" }); + }); + + it("collects defaults from a root allOf", () => { + expect( + collectSchemaDefaults({ + type: "object", + allOf: [ + { + type: "object", + properties: { merged: { type: "string", default: "m" } }, + }, + ], + }), + ).toEqual({ merged: "m" }); + }); + + it("blocks submission while no branch is satisfied", () => { + expect(hasMissingRequiredFields(UNION, {})).toBe(true); + expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); + }); + + it("allows submission once one branch is satisfied", () => { + expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe( + false, + ); + }); + + it("gates on required fields declared only in a root allOf", () => { + const schema: InspectorFormSchema = { + type: "object", + allOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + }, + ], + }; + expect(hasMissingRequiredFields(schema, {})).toBe(true); + expect(hasMissingRequiredFields(schema, { a: "x" })).toBe(false); + }); +}); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 093adbc9ca..b6ed3b4cee 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -2,6 +2,7 @@ import { admitsNull, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; +import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; export type JsonValue = | string @@ -58,6 +59,11 @@ export type InspectorFormSchema = { const?: JsonValue; oneOf?: (InspectorFormSchema | JsonSchemaConst)[]; anyOf?: (InspectorFormSchema | JsonSchemaConst)[]; + // Root composition the form flattens before rendering (#2123). `allOf` is + // merged into the schema; `oneOf`/`anyOf` at the root become the branches the + // Variant picker chooses between, labelled via `discriminator` when present. + allOf?: InspectorFormSchema[]; + discriminator?: { propertyName?: string }; $ref?: string; }; @@ -119,7 +125,13 @@ export function getDataType(value: JsonValue): DataType { export function collectSchemaDefaults( schema: InspectorFormSchema, ): Record { - const properties = schema.properties ?? {}; + // Seed from the shape the form actually renders: root `allOf` merged in, and + // for a root union the branch the picker starts on (#2123). Seeding every + // branch would put fields of shapes the call is not making into the + // arguments; seeding none would leave the branch's defaults — its + // discriminator `const` among them — displayed but never submitted. + const { base, branches } = resolveRootUnion(schema); + const properties = (branches[0]?.schema ?? base).properties ?? {}; const result: Record = {}; for (const [fieldName, rawSchema] of Object.entries(properties)) { // Collapse a nullable union first, for the same reason `SchemaForm` does: @@ -129,6 +141,11 @@ export function collectSchemaDefaults( const fieldSchema = normalizeNullableUnion(rawSchema); if (fieldSchema.default !== undefined) { result[fieldName] = fieldSchema.default; + } else if (fieldSchema.const !== undefined) { + // `const` is a one-value enumeration, so the only submittable value is + // already known — seeding it spares the user hand-typing a discriminator + // the schema has fixed (#2123), and matches what a `default` would do. + result[fieldName] = fieldSchema.const; } else if (fieldSchema.type === "object" && fieldSchema.properties) { const nested = collectSchemaDefaults(fieldSchema); if (Object.keys(nested).length > 0) { @@ -160,6 +177,25 @@ export function collectSchemaDefaults( export function hasMissingRequiredFields( schema: InspectorFormSchema, values: Record, +): boolean { + // Root composition first, so a schema keeping its `required` on branches is + // gated at all (#2123). For a union the answer is selection-independent by + // construction: valid arguments must satisfy *some* branch, so the submit is + // blocked only when **every** branch is still missing something. That is + // deliberately weaker than gating on the branch the picker is showing — this + // function is handed `values`, never the selection — but it is sound in the + // direction that matters: it never blocks arguments the schema accepts. + const { base, branches } = resolveRootUnion(schema); + if (branches.length > 0) { + return branches.every((branch) => hasMissingIn(branch.schema, values)); + } + return hasMissingIn(base, values); +} + +/** {@link hasMissingRequiredFields} against one already-flattened schema. */ +function hasMissingIn( + schema: InspectorFormSchema, + values: Record, ): boolean { const required = schema.required ?? []; const properties = schema.properties ?? {}; diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index 80cdf95de7..9c8c962381 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -86,3 +86,39 @@ describe("toolRowKey / findToolByRowKey", () => { expect(findToolByRowKey(tools, "1:get_weather")).toBeUndefined(); }); }); + +describe("hasInputFields with root composition (#2123)", () => { + const tool = (inputSchema: Tool["inputSchema"]): Tool => ({ + name: "t", + inputSchema, + }); + + it("sees fields declared on a root union branch", () => { + expect( + hasInputFields( + tool({ + type: "object", + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }), + ), + ).toBe(true); + }); + + it("sees fields declared on a root allOf", () => { + expect( + hasInputFields( + tool({ + type: "object", + allOf: [{ type: "object", properties: { a: { type: "string" } } }], + }), + ), + ).toBe(true); + }); + + it("still reports no fields for a bare object schema", () => { + expect(hasInputFields(tool({ type: "object" }))).toBe(false); + }); +}); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index 4231a83e62..e253ef0e9c 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -1,4 +1,5 @@ import type { Tool } from "@modelcontextprotocol/client"; +import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; /** * Returns the display label for an MCP entity that follows the BaseMetadata @@ -14,10 +15,21 @@ export function resolveDisplayLabel(name: string, title?: string): string { * True when the tool's input schema declares at least one property — used by * App-flow callers to decide whether to render a form or auto-launch. Kept in * one place so the definition of "has fields" stays consistent if it ever - * grows to consider `additionalProperties`, `anyOf`, etc. + * grows to consider `additionalProperties` etc. + * + * Root composition is resolved first, since a schema declaring its fields on a + * root `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with + * such a schema would otherwise launch with empty arguments rather than asking + * for them. */ export function hasInputFields(tool: Tool): boolean { - return Object.keys(tool.inputSchema.properties ?? {}).length > 0; + const { base, branches } = resolveRootUnion(tool.inputSchema); + return ( + Object.keys(base.properties ?? {}).length > 0 || + branches.some( + (branch) => Object.keys(branch.schema.properties ?? {}).length > 0, + ) + ); } /** diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index ba1533e940..2df51dbd7d 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -1,4 +1,5 @@ import type { Tool } from "@modelcontextprotocol/client"; +import { resolveRootUnion } from "./rootUnion.js"; /** * JSON value type used across the inspector project @@ -132,7 +133,21 @@ export function convertToolParameters( params: Record, ): Record { const result: Record = {}; - const properties = tool.inputSchema?.properties || {}; + // A property's schema can live on a root composition branch rather than on + // the root itself (#2123). Reading only the root's `properties` there finds + // no schema for any argument, so every value would be sent as the string the + // user typed — `--tool-arg count=3` reaching the server as `"3"`. The union + // is flattened by merging every branch, because the CLI has no branch + // selection to consult: an argument named by one branch is coerced by that + // branch's schema, and a name two branches type differently keeps the first, + // which is no worse than the untyped passthrough it replaces. + const { base, branches } = resolveRootUnion(tool.inputSchema ?? {}); + const properties: Record = { ...base.properties }; + for (const branch of branches) { + for (const name of branch.ownFields) { + properties[name] ??= branch.schema.properties?.[name]; + } + } for (const [key, value] of Object.entries(params)) { const paramSchema = properties[key] as ParameterSchema | undefined; diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts new file mode 100644 index 0000000000..b73f6ba8eb --- /dev/null +++ b/core/json/rootUnion.ts @@ -0,0 +1,236 @@ +/** + * Flattening of the JSON Schema **composition** keywords that a tool's + * `inputSchema` may carry at its **root** — `allOf`, and a top-level `anyOf` / + * `oneOf` union — into the plain object schema a form renderer can build fields + * from. + * + * Both form builders — the web client's `SchemaForm` and the TUI's + * `schemaToForm` — enumerate the root schema's `properties` and nothing else. + * A schema that keeps its fields on composition branches therefore rendered + * **no controls at all**: not a branch picker, not even the raw-JSON fallback a + * union-typed *property* already gets, so the tool could only ever be called + * with empty arguments (#2123). The 2026-07-28 revision makes such a schema + * explicitly legal — `type: "object"` is required at the root, and "any JSON + * Schema 2020-12 keyword may appear alongside `type`, including composition + * keywords". + * + * Shared for the same reason {@link ./nullableUnion.ts} is: the two clients + * would otherwise grow separate answers to "which schemas can I render", and + * the CLI's argument coercion (`convertToolParameters`) needs the same view of + * where a property's schema lives. + * + * Deliberately **not** a JSON Schema evaluator. It rewrites nothing it cannot + * do faithfully: `not`, and branches that are not object schemas, are left + * alone rather than guessed at (see {@link resolveRootUnion}). + */ + +/** + * The keywords this module reads. Both clients' own schema types are + * structurally assignable to this — the shape is the minimum needed to + * *recognize* root composition, not a full JSON Schema model. + */ +export interface RootUnionSchema { + type?: string | string[]; + title?: string; + properties?: Record; + required?: string[]; + allOf?: readonly unknown[]; + anyOf?: readonly unknown[]; + oneOf?: readonly unknown[]; + /** + * OpenAPI's discriminator, which real servers emit beside `oneOf` (the issue's + * own repro does). Read only to *label* a branch — never to validate. + */ + discriminator?: { propertyName?: string }; +} + +/** + * What a resolved schema is: the caller's own type, intersected with the + * keywords this module may have *added* to it. + * + * The intersection is load-bearing rather than decorative. A schema that keeps + * every field on a branch declares no `properties` of its own, so returning the + * caller's `T` unchanged would hand back a type on which `properties` does not + * exist — precisely the property the flattening was performed to produce. + */ +export type ResolvedSchema = T & RootUnionSchema; + +/** One selectable alternative of a root union. */ +export interface RootUnionBranch { + /** + * The base schema merged with this branch: the object schema a renderer + * builds fields from while this alternative is selected. + */ + schema: ResolvedSchema; + /** Human-readable name for the picker — see {@link branchLabel}. */ + label: string; + /** Names this branch contributes that the base does not. */ + ownFields: string[]; +} + +/** What {@link resolveRootUnion} decomposes a root schema into. */ +export interface ResolvedRootUnion { + /** + * The schema without its composition keywords: the root's own `properties` / + * `required` with every `allOf` branch merged in. Renderable on its own, and + * what a renderer uses when there is no union. + */ + base: ResolvedSchema; + /** + * One entry per union alternative, or **empty** when the root carries no + * usable union — which is the overwhelmingly common case, so callers can + * treat a non-empty array as "this schema needs a branch picker". + */ + branches: RootUnionBranch[]; +} + +/** Narrow a composition member to a readable object, or `null` if it isn't one. */ +function toBranch(value: unknown): RootUnionSchema | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + return value as RootUnionSchema; +} + +/** + * Whether a branch contributes anything a form can render. + * + * A `{ type: "null" }` member — the nullable encoding {@link + * ./nullableUnion.ts} owns — and a `$ref`-only or empty branch carry no + * properties, so offering them as alternatives would produce a picker whose + * options render nothing. + */ +function hasFields(branch: RootUnionSchema): boolean { + return ( + branch.properties !== undefined && Object.keys(branch.properties).length > 0 + ); +} + +/** + * Merge a composition branch's `properties` and `required` into a base schema. + * + * Both keywords are **conjunctive** where they meet: a value satisfying an + * `allOf` branch satisfies the base *and* the branch, and a value matching a + * union branch must satisfy the root's own constraints too. So properties union + * (branch wins a name collision, being the more specific declaration) and + * `required` unions. + */ +function mergeBranch( + base: T, + branch: RootUnionSchema, +): ResolvedSchema { + const properties = { ...base.properties, ...branch.properties }; + const required = [ + ...(base.required ?? []), + ...(branch.required ?? []).filter( + (name) => !(base.required ?? []).includes(name), + ), + ]; + // One cast, owned here: a branch member is `unknown` on the wire, so its + // property schemas are whatever the server sent however `T` declares them — + // exactly the situation `normalizeNullableUnion`'s hoist documents. Every + // consumer reads a property schema defensively, and the merged object is + // otherwise structurally `T`. + return { + ...base, + ...(Object.keys(properties).length > 0 ? { properties } : {}), + ...(required.length > 0 ? { required } : {}), + } as ResolvedSchema; +} + +/** Strip the composition keywords a resolved schema has absorbed. */ +function withoutComposition( + schema: T, +): ResolvedSchema { + const { allOf: _allOf, anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema; + return rest as ResolvedSchema; +} + +/** + * A branch's display name, in decreasing order of how deliberate it is: + * + * 1. the branch's own `title` — the author naming it outright; + * 2. the `const` of the discriminator property, when the root names one; + * 3. the `const` of the branch's only constant-valued property, which is what a + * discriminated union looks like without an OpenAPI `discriminator` (the + * `kind: { const: "email" }` shape) — restricted to a *single* candidate, so + * a branch with several constants is not labelled by an arbitrary one; + * 4. a positional fallback. + */ +function branchLabel( + branch: RootUnionSchema, + index: number, + discriminatorProperty: string | undefined, +): string { + if (typeof branch.title === "string" && branch.title.trim() !== "") { + return branch.title; + } + const properties = branch.properties ?? {}; + const constOf = (name: string): string | null => { + const property = toBranch(properties[name]) as { const?: unknown } | null; + const value = property?.const; + return typeof value === "string" || typeof value === "number" + ? String(value) + : null; + }; + if (discriminatorProperty !== undefined) { + const value = constOf(discriminatorProperty); + if (value !== null) return value; + } + const constants = Object.keys(properties) + .map((name) => constOf(name)) + .filter((value): value is string => value !== null); + if (constants.length === 1) return constants[0]; + return `Option ${index + 1}`; +} + +/** + * Decompose a root schema into the object schema a form renders and, when the + * root is a union, the alternatives a picker offers. + * + * `allOf` is merged unconditionally — its branches are conjunctive, so there is + * one correct rendering and no choice to present. A `oneOf` / `anyOf` becomes + * `branches` only when **every** member is an object schema carrying fields: + * a union mixing renderable and unrenderable members would give a picker + * options that show nothing, and the whole point of this module is to stop + * producing a form that cannot express the call. `oneOf` wins when a schema + * carries both, being the stricter of the two. + */ +export function resolveRootUnion( + schema: T, +): ResolvedRootUnion { + const merged = (schema.allOf ?? []).reduce>( + (acc, member) => { + const branch = toBranch(member); + return branch === null ? acc : mergeBranch(acc, branch); + }, + schema as ResolvedSchema, + ); + const base = withoutComposition(merged); + + const members = schema.oneOf ?? schema.anyOf ?? []; + const branches = members.map(toBranch); + if ( + branches.length === 0 || + branches.some((branch) => branch === null || !hasFields(branch)) + ) { + return { base, branches: [] }; + } + + const discriminatorProperty = schema.discriminator?.propertyName; + const baseFields = new Set(Object.keys(base.properties ?? {})); + return { + base, + branches: branches + .filter((branch): branch is RootUnionSchema => branch !== null) + .map((branch, index) => ({ + // `base` is already composition-free and `mergeBranch` copies only + // `properties`/`required` off the branch, so the merge stays that way. + schema: mergeBranch(base, branch), + label: branchLabel(branch, index, discriminatorProperty), + ownFields: Object.keys(branch.properties ?? {}).filter( + (name) => !baseFields.has(name), + ), + })), + }; +} diff --git a/test-servers/configs/root-union-schemas-http.json b/test-servers/configs/root-union-schemas-http.json new file mode 100644 index 0000000000..33896b7459 --- /dev/null +++ b/test-servers/configs/root-union-schemas-http.json @@ -0,0 +1,67 @@ +{ + "serverInfo": { + "name": "root-union-schemas-showcase", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "get_weather" }], + "rawToolSchemas": { + "echo": { + "inputSchema": { + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"], + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "email" }, + "address": { "type": "string" } + }, + "required": ["kind", "address"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "const": "sms" }, + "phone": { "type": "string" } + }, + "required": ["kind", "phone"] + } + ] + } + }, + "get_weather": { + "inputSchema": { + "type": "object", + "discriminator": { "propertyName": "by" }, + "oneOf": [ + { + "type": "object", + "title": "By city", + "properties": { + "by": { "type": "string", "const": "city" }, + "city": { "type": "string" } + }, + "required": ["by", "city"] + }, + { + "type": "object", + "title": "By coordinates", + "properties": { + "by": { "type": "string", "const": "coords" }, + "lat": { "type": "number" }, + "lon": { "type": "number" } + }, + "required": ["by", "lat", "lon"] + } + ] + } + } + }, + "transport": { + "type": "streamable-http", + "port": 6604 + } +} From 66b3274fa67aefc9e1fb3f5d6b20852216dca846 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 00:50:59 -0400 Subject: [PATCH 012/213] docs(smoke): align the smoke headers with the pre-push gate wiring The three headers still said the non-Chromium engines were on demand and that 'nothing runs it for you'. Both became false when Firefox moved into npm run ci; I updated the README and AGENTS.md for that change and did not carry it into the files themselves. Each header now names the three tiers separately rather than collapsing them into a CI/not-CI binary: GitHub CI is Chromium only, the local pre-push gate is Chromium and Firefox, and WebKit is on demand. That distinction is the whole point of the arrangement, so a header that blurs it is worse than one that is merely out of date. Addresses Copilot review 5026731741 on #2133. Signed-off-by: cliffhall --- scripts/smoke-web-app.mjs | 10 +++++++--- scripts/smoke-web-browser.mjs | 5 +++-- scripts/smoke-web-elicitation.mjs | 10 +++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index 72720427f8..cd1e932330 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -44,9 +44,13 @@ * ── Which engine ──────────────────────────────────────────────────────────── * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`). **CI runs Chromium only.** The other engines are an on-demand tool, - * not a gate: `SMOKE_BROWSER=firefox npm run smoke:web:engine` before touching - * the sandbox is cheap and worth doing, but nothing runs it for you (#2086). + * `webkit`). Three tiers, deliberately (#2086): + * + * - **GitHub CI** runs this smoke in **Chromium** only. + * - **`npm run ci`**, the local pre-push gate, runs it in **Chromium and + * Firefox** — the Firefox pass is `smoke:web:firefox`, and it is the one + * gate step with no GitHub CI counterpart. + * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:app`. * * Firefox passes. WebKit fails this smoke for reasons nobody has identified and * nobody is investigating: it does not reproduce in real Safari, and an isolated diff --git a/scripts/smoke-web-browser.mjs b/scripts/smoke-web-browser.mjs index 65cc2f5291..04342cf2c4 100644 --- a/scripts/smoke-web-browser.mjs +++ b/scripts/smoke-web-browser.mjs @@ -46,8 +46,9 @@ * Launching the browser (and resolving Playwright from clients/web, which has * its own gotcha — see `lib/headless-browser.mjs`) is delegated to that module, * which is also where `SMOKE_BROWSER` picks the engine: `chromium` (the - * default), `firefox`, or `webkit` (#2086). **CI runs Chromium only**; the other - * engines are an on-demand tool rather than a gate. This smoke passes in all + * default), `firefox`, or `webkit` (#2086). GitHub CI runs this in **Chromium** + * only; `npm run ci`, the local pre-push gate, also runs it in **Firefox** via + * `smoke:web:firefox`; **WebKit is on demand only**. This smoke passes in all * three — it is the two App smokes that fail under WebKit (see their headers). * * The engine question here is narrower than in the App smokes — this asserts a clean diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 4c485ee035..364ea7308f 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -25,9 +25,13 @@ * proof to a PR); unset, it asserts only. * * `SMOKE_BROWSER` picks the engine (`chromium` — the default — `firefox`, or - * `webkit`). **CI runs Chromium only.** The other engines are an on-demand tool, - * not a gate: `SMOKE_BROWSER=firefox npm run smoke:web:engine` before touching - * the sandbox is cheap and worth doing, but nothing runs it for you (#2086). + * `webkit`). Three tiers, deliberately (#2086): + * + * - **GitHub CI** runs this smoke in **Chromium** only. + * - **`npm run ci`**, the local pre-push gate, runs it in **Chromium and + * Firefox** — the Firefox pass is `smoke:web:firefox`, and it is the one + * gate step with no GitHub CI counterpart. + * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:app`. * * Firefox passes. WebKit fails this smoke for reasons nobody has identified and * nobody is investigating: it does not reproduce in real Safari, and an isolated From 2782ed75d1fdc5ca654fd8b30fefefcd0204637a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 01:01:54 -0400 Subject: [PATCH 013/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20const=20precedence,=20TUI=20variant=20model,=20decl?= =?UTF-8?q?ine=20both-keyword=20unions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: decline a schema carrying BOTH oneOf and anyOf rather than reading one and silently dropping the other's constraints. - SchemaForm: handle `const` before every type dispatch and for every constant type, so a numeric/boolean constant, or a schema carrying both `const` and `enum`, can no longer offer a forbidden value. - collectSchemaDefaults / TUI initialValue: `const` outranks `default`, which is an annotation a schema may set to something its const rejects. - convertToolParameters: resolve from the first branch's merged schema, so a branch's specialization of a root-declared property is what types the arg. - TUI: ink-form keys values by field name across the whole form, so two branches' `kind` were one field and the later section's initial value decided what the earlier submitted. Branch fields now render under prefixed names behind a Variant select, and decodeFormValues translates back on submit, dropping every branch but the chosen one. Signed-off-by: cliffhall --- AGENTS.md | 19 ++- README.md | 6 +- clients/tui/__tests__/schemaToForm.test.ts | 102 +++++++++++++++- clients/tui/src/components/ToolTestModal.tsx | 10 +- clients/tui/src/utils/schemaToForm.ts | 115 +++++++++++++++--- .../groups/SchemaForm/SchemaForm.test.tsx | 51 ++++++++ .../groups/SchemaForm/SchemaForm.tsx | 49 +++++--- clients/web/src/test/core/jsonUtils.test.ts | 19 +++ clients/web/src/test/core/rootUnion.test.ts | 10 +- clients/web/src/utils/jsonUtils.test.ts | 7 +- clients/web/src/utils/jsonUtils.ts | 14 ++- core/json/jsonUtils.ts | 20 ++- core/json/rootUnion.ts | 16 ++- 13 files changed, 370 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e54e134675..828828ad7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,12 +171,19 @@ v2/main/ │ │ # SchemaForm (the Variant picker + the branch-change │ │ # value pruning), TUI schemaToForm (a section per │ │ # branch, its fields forced OPTIONAL since only one -│ │ # alternative applies), and convertToolParameters -│ │ # (which branch's schema types a CLI --tool-arg). -│ │ # Declines a union whose members are not ALL -│ │ # field-carrying objects rather than offering a -│ │ # picker with options that render nothing, and does -│ │ # not interpret `not` at all — #2123; +│ │ # alternative applies — and rendered under PREFIXED +│ │ # names behind a variant select, because ink-form +│ │ # keys values by field name across the WHOLE form, +│ │ # so two branches' `kind` would otherwise be one +│ │ # field; schemaToForm.decodeFormValues translates +│ │ # back on submit), and convertToolParameters (which +│ │ # branch's schema types a CLI --tool-arg). +│ │ # DECLINES rather than half-reads: a union whose +│ │ # members are not ALL field-carrying objects, and a +│ │ # schema carrying BOTH oneOf and anyOf (independent +│ │ # keywords, satisfied together — picking one drops +│ │ # real constraints). Does not interpret `not` at +│ │ # all — #2123; │ │ # schemaLint.ts: tool-schema PORTABILITY lint — │ │ # constructs that are legal JSON Schema and are │ │ # refused or mishandled by real MCP clients (a bare diff --git a/README.md b/README.md index f9a5e5fcc2..9890e969ca 100644 --- a/README.md +++ b/README.md @@ -300,11 +300,13 @@ Open the Tools tab and select `echo`. Above the fields is a **Variant** picker l Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making. -The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, and the fields in it are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. Untouched fields report no value and are dropped before the call, so the sections you skip contribute nothing. +The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, preceded by a **Variant** select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. + +The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped. The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. -Two things it deliberately does **not** do. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — the schema falls back to whatever its root `properties` describe. And `not` is not interpreted at all: there is no faithful form for "anything except this". +Three things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing. A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this". #### Unportable tool schemas diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index f1f763c59a..cf5afb8ce4 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { schemaToForm } from "../src/utils/schemaToForm.js"; +import { + decodeFormValues, + schemaToForm, + VARIANT_FIELD, +} from "../src/utils/schemaToForm.js"; describe("schemaToForm", () => { it("returns an empty Parameters section when there is no schema", () => { @@ -441,18 +445,43 @@ describe("schemaToForm", () => { "sms", ]); expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ + VARIANT_FIELD, "note", ]); + }); + + it("names branch fields uniquely, since ink-form scopes by name alone", () => { + // Both branches declare `kind`. Rendered under their real names they + // would be one field, and the later section's initial value would decide + // what the earlier section submits. + const form = schemaToForm(UNION, "union_tool"); expect(form.sections[1]!.fields.map((field) => field.name)).toEqual([ - "kind", - "address", + "__b0__kind", + "__b0__address", + ]); + expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([ + "__b1__kind", + "__b1__count", ]); }); + it("offers a variant select listing the alternatives", () => { + const form = schemaToForm(UNION, "union_tool"); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: VARIANT_FIELD, + type: "select", + initialValue: "0", + options: [ + { label: "email", value: "0" }, + { label: "sms", value: "1" }, + ], + }); + }); + it("keeps a branch's typed fields typed", () => { const form = schemaToForm(UNION, "union_tool"); expect(form.sections[2]!.fields[1]).toMatchObject({ - name: "count", + name: "__b1__count", type: "integer", }); }); @@ -467,11 +496,74 @@ describe("schemaToForm", () => { it("seeds a branch's discriminator const so it need not be typed", () => { const form = schemaToForm(UNION, "union_tool"); expect(form.sections[1]!.fields[0]).toMatchObject({ - name: "kind", + name: "__b0__kind", initialValue: "email", }); }); + it("prefers a const over a conflicting default", () => { + const form = schemaToForm( + { + type: "object", + properties: { v: { type: "string", const: "a", default: "b" } }, + }, + "const_tool", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "a" }); + }); + + describe("decodeFormValues", () => { + it("submits the chosen branch's fields under their real names", () => { + expect( + decodeFormValues(UNION, { + [VARIANT_FIELD]: "0", + note: "hi", + __b0__kind: "email", + __b0__address: "a@b.c", + __b1__kind: "sms", + __b1__count: 3, + }), + ).toEqual({ note: "hi", kind: "email", address: "a@b.c" }); + }); + + it("drops the branches the call is not making", () => { + expect( + decodeFormValues(UNION, { + [VARIANT_FIELD]: "1", + __b0__kind: "email", + __b0__address: "a@b.c", + __b1__kind: "sms", + __b1__count: 3, + }), + ).toEqual({ kind: "sms", count: 3 }); + }); + + it("omits a branch field the user never filled", () => { + expect( + decodeFormValues(UNION, { + [VARIANT_FIELD]: "0", + __b0__kind: "email", + }), + ).toEqual({ kind: "email" }); + }); + + it("falls back to the first branch on an unusable selection", () => { + expect( + decodeFormValues(UNION, { + [VARIANT_FIELD]: "nonsense", + __b0__kind: "email", + }), + ).toEqual({ kind: "email" }); + }); + + it("returns the values untouched for a schema with no root union", () => { + const values = { message: "hi" }; + expect(decodeFormValues({ properties: { message: {} } }, values)).toBe( + values, + ); + }); + }); + it("merges a root allOf into the parameters section", () => { const form = schemaToForm( { diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index 5148b93ee7..3b2d28caf4 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -5,7 +5,7 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Tool, CallToolResult } from "@modelcontextprotocol/client"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { schemaToForm } from "../utils/schemaToForm.js"; +import { decodeFormValues, schemaToForm } from "../utils/schemaToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; interface ToolTestModalProps { @@ -114,9 +114,15 @@ export function ToolTestModal({ { isActive: true }, ); - const handleFormSubmit = async (values: Record) => { + const handleFormSubmit = async (rawValues: Record) => { if (!inspectorClient || !tool) return; + // A root union renders every alternative as its own section, under prefixed + // field names, because ink-form scopes values by name across the whole form + // (#2123). This turns them back into the arguments the server declared: + // the base fields plus the chosen branch's, and nothing from the others. + const values = decodeFormValues(tool.inputSchema, rawValues); + setState("loading"); const startTime = Date.now(); diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 321b10ecf4..2558dc315a 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -70,6 +70,23 @@ interface JsonSchemaObject { discriminator?: { propertyName?: string }; } +/** + * The select that names which alternative of a root union the call is making. + * + * ink-form keeps **one** value object for the whole form, keyed by field name + * alone — sections are visual grouping, not scope. So two branches of a + * discriminated union both declaring `kind` are the *same* field: the later + * section's initial value wins, and filling the first branch's section would + * submit the second branch's discriminator. Prefixing each branch's fields and + * choosing between them explicitly is what makes the alternatives independent. + */ +export const VARIANT_FIELD = "__variant"; + +/** The form-local name a branch's field is rendered under. */ +function branchFieldName(branchIndex: number, name: string): string { + return `__b${branchIndex}__${name}`; +} + /** * Converts a JSON Schema to ink-form structure */ @@ -88,30 +105,88 @@ export function schemaToForm( // could only be called with empty arguments. const { base, branches } = resolveRootUnion(schema); - const sections: FormSection[] = [ - { title: "Parameters", fields: buildFields(base) }, - ]; - - // ink-form is static — there is no branch picker to hide the alternatives - // behind — so every branch gets its own section and the user fills the one - // they mean. A branch's fields are rendered **optional** whatever the branch - // says: only one alternative applies to a given call, so requiring them would - // make a form that can never be submitted. An untouched field reports no - // value and is dropped before the call, so the sections the user skipped - // contribute nothing to the arguments. - for (const branch of branches) { + const parameters = buildFields(base); + if (branches.length > 0) { + // ink-form is static, so there is no picker that can swap the fields out. + // Every branch is rendered instead, and this select says which one the + // call means — read back by {@link decodeFormValues}, which drops the rest. + parameters.unshift({ + type: "select", + name: VARIANT_FIELD, + label: "Variant", + required: true, + initialValue: "0", + options: branches.map((branch, index) => ({ + label: branch.label, + value: String(index), + })), + } as FormField); + } + + const sections: FormSection[] = [{ title: "Parameters", fields: parameters }]; + + // One section per alternative, its fields **optional** whatever the branch + // says: only one alternative applies to a call, so requiring them would build + // a form that can never be submitted. + branches.forEach((branch, index) => { const ownProperties = Object.fromEntries( - branch.ownFields.map((name) => [name, branch.schema.properties?.[name]]), + branch.ownFields.map((name) => [ + branchFieldName(index, name), + branch.schema.properties?.[name], + ]), ); sections.push({ title: branch.label, fields: buildFields({ properties: ownProperties }), }); - } + }); return { title, sections }; } +/** + * Turn what the form submitted back into the arguments the server expects: + * the base fields, plus the fields of the branch the {@link VARIANT_FIELD} + * select names, under their real property names. + * + * Every other branch's fields are dropped rather than sent — they describe a + * shape this call is not making, and the user filled at most one section. Call + * this on the way out of the form; for a schema with no root union it returns + * the values unchanged, so it is safe to apply unconditionally. + */ +export function decodeFormValues( + schema: JsonSchemaObject | null | undefined, + values: Record, +): Record { + const { branches } = resolveRootUnion(schema ?? {}); + if (branches.length === 0) { + return values; + } + + const raw = values[VARIANT_FIELD]; + const selected = Number(raw); + const branchIndex = + Number.isInteger(selected) && selected >= 0 && selected < branches.length + ? selected + : 0; + + const decoded: Record = {}; + for (const [name, value] of Object.entries(values)) { + // Skip the select itself and every branch's prefixed field; the chosen + // branch's are re-added below under the names the schema declares. + if (name !== VARIANT_FIELD && !name.startsWith("__b")) { + decoded[name] = value; + } + } + for (const name of branches[branchIndex]!.ownFields) { + const value = values[branchFieldName(branchIndex, name)]; + if (value !== undefined) { + decoded[name] = value; + } + } + return decoded; +} + /** Build the ink-form fields for one already-flattened object schema. */ function buildFields(schema: JsonSchemaObject): FormField[] { const fields: FormField[] = []; @@ -204,10 +279,14 @@ function buildFields(schema: JsonSchemaObject): FormField[] { } // Set initial value from default (ink-form FormField allows initialValue for some types). - // A `const` is seeded the same way: it is a one-value enumeration, so the - // only submittable value is already known and the user would otherwise have - // to hand-type a union's discriminator (#2123). - const initialValue = property.default ?? property.const; + // A `const` is seeded the same way and OUTRANKS `default`: it is a + // one-value enumeration, so the only submittable value is already known and + // the user would otherwise have to hand-type a union's discriminator + // (#2123), while `default` is an annotation a schema may set to something + // its own `const` rejects. Tested against `undefined` rather than `??` + // chained, so an explicit `null` default is honored as a value. + const initialValue = + property.const !== undefined ? property.const : property.default; if (initialValue !== undefined) { (field as FormField & { initialValue?: unknown }).initialValue = initialValue; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 4ba2cf2e84..a35189d89d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2049,6 +2049,57 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(kind.value).toBe("email"); }); + it("renders a non-string constant read-only too", () => { + // Reached before the number/boolean dispatch, so neither offers a value + // the `const` forbids. + renderWithMantine( + , + ); + expect( + (screen.getByRole("textbox", { name: /N/ }) as HTMLInputElement).value, + ).toBe("7"); + expect( + (screen.getByRole("textbox", { name: /B/ }) as HTMLInputElement).value, + ).toBe("true"); + }); + + it("keeps a const out of an enum select", () => { + // A schema carrying both would otherwise reach the select and offer the + // enum's other members, each of which the `const` rejects. + renderWithMantine( + , + ); + const mode = screen.getByRole("textbox", { + name: /Mode/, + }) as HTMLInputElement; + expect(mode.readOnly).toBe(true); + expect(mode.value).toBe("fast"); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 495779bc55..6d4578efd9 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -712,6 +712,35 @@ export function SchemaForm({ const description = fieldSchema.description; const rawValue = resolveValue(values[fieldName], fieldSchema); + // A field pinned to a single value, checked **before** any type dispatch. + // `const` admits exactly one value, so every widget below it — the enum + // select, the number box, the checkbox — would offer values the schema + // forbids, and a schema carrying both `const` and `enum` would otherwise + // reach the select and submit a sibling of the one legal answer. Rendered + // read-only rather than editable for the same reason. + // + // Display only: the value that is *submitted* comes from `values`, seeded + // from the same `const` by `collectSchemaDefaults`, so a non-string + // constant keeps its type on the wire however it is shown here. + if (fieldSchema.const !== undefined) { + const constValue = fieldSchema.const; + return ( + + ); + } + // string with enum if (fieldSchema.type === "string" && fieldSchema.enum) { return ( @@ -750,26 +779,6 @@ export function SchemaForm({ ); } - // A string pinned to a single value. Rendered read-only rather than as an - // editable box: `const` admits exactly one value, so anything the user - // could type into it produces a call the schema rejects. This is what a - // discriminated union's `kind`/`by` field is (#2123), and the picker has - // already set it — but the rule is the keyword's, not the union's, so it - // holds for a lone `const` property too. - if (typeof fieldSchema.const === "string") { - return ( - - ); - } - // plain string if (fieldSchema.type === "string") { const clearButton = rawValue ? ( diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 6ae1e151d2..522c0bbd0e 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -104,6 +104,25 @@ describe("JSON Utils", () => { ).toEqual({ note: "hi", count: 42, enabled: true }); }); + it("prefers a branch's specialization of a root property (#2123)", () => { + const specializing: Tool = { + name: "specializing", + inputSchema: { + type: "object", + // The root declares the name but constrains nothing; the branch is + // what says it is a number. + properties: { count: {} }, + anyOf: [ + { type: "object", properties: { count: { type: "number" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }, + }; + expect(convertToolParameters(specializing, { count: "3" })).toEqual({ + count: 3, + }); + }); + it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { const allOfTool: Tool = { name: "allof-tool", diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 101b211e4d..21419d5108 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -63,13 +63,17 @@ describe("resolveRootUnion", () => { expect(branches[0].schema.anyOf).toBeUndefined(); }); - it("prefers oneOf when a schema carries both", () => { - const { branches } = resolveRootUnion({ + it("declines a schema carrying both oneOf and anyOf", () => { + // Independent keywords a value satisfies together, so reading one and + // dropping the other would build a form missing real constraints. + const { base, branches } = resolveRootUnion({ type: "object", + properties: { note: { type: "string" } }, oneOf: [EMAIL], anyOf: [EMAIL, SMS], }); - expect(branches).toHaveLength(1); + expect(branches).toEqual([]); + expect(Object.keys(base.properties ?? {})).toEqual(["note"]); }); it("merges allOf branches unconditionally", () => { diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 7c84f1e7dc..f788b5dd2d 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -382,7 +382,10 @@ describe("root composition (#2123)", () => { ).toEqual({ version: "1" }); }); - it("prefers an explicit default over a const", () => { + it("prefers a const over a conflicting default", () => { + // `default` is an annotation, not a constraint, so a schema may advertise + // one its own `const` rejects — seeding it would submit an invalid value + // through a read-only field. expect( collectSchemaDefaults({ type: "object", @@ -390,7 +393,7 @@ describe("root composition (#2123)", () => { v: { type: "string", const: "a", default: "b" }, }, }), - ).toEqual({ v: "b" }); + ).toEqual({ v: "a" }); }); it("collects defaults from a root allOf", () => { diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index b6ed3b4cee..0e29195eaf 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -139,13 +139,19 @@ export function collectSchemaDefaults( // 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.const !== undefined) { + if (fieldSchema.const !== undefined) { // `const` is a one-value enumeration, so the only submittable value is // already known — seeding it spares the user hand-typing a discriminator - // the schema has fixed (#2123), and matches what a `default` would do. + // the schema has fixed (#2123). + // + // It outranks `default`, which JSON Schema defines as an annotation + // rather than a constraint: a schema may advertise a default its own + // `const` rejects, and seeding that would submit an invalid argument + // through a field rendered read-only, leaving the user no way to correct + // it. result[fieldName] = fieldSchema.const; + } else if (fieldSchema.default !== undefined) { + result[fieldName] = fieldSchema.default; } else if (fieldSchema.type === "object" && fieldSchema.properties) { const nested = collectSchemaDefaults(fieldSchema); if (Object.keys(nested).length > 0) { diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 2df51dbd7d..da9c51a266 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -142,10 +142,22 @@ export function convertToolParameters( // branch's schema, and a name two branches type differently keeps the first, // which is no worse than the untyped passthrough it replaces. const { base, branches } = resolveRootUnion(tool.inputSchema ?? {}); - const properties: Record = { ...base.properties }; - for (const branch of branches) { - for (const name of branch.ownFields) { - properties[name] ??= branch.schema.properties?.[name]; + // Start from the FIRST branch's merged schema rather than from the base: a + // branch may *specialize* a property the root also declares (root + // `count: {}`, branch `count: { type: "number" }`), and merging branch-last + // is what gives the typed declaration — starting from the base would keep the + // untyped one and send `count=3` as `"3"`, the very coercion this restores. + // Later branches then contribute only names not seen yet, so first-branch + // precedence matches what the web form seeds and what a name two branches + // type differently resolves to. + const properties: Record = { + ...(branches[0]?.schema.properties ?? base.properties), + }; + for (const branch of branches.slice(1)) { + for (const [name, schema] of Object.entries( + branch.schema.properties ?? {}, + )) { + properties[name] ??= schema; } } diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index b73f6ba8eb..88f4c0081c 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -193,8 +193,17 @@ function branchLabel( * `branches` only when **every** member is an object schema carrying fields: * a union mixing renderable and unrenderable members would give a picker * options that show nothing, and the whole point of this module is to stop - * producing a form that cannot express the call. `oneOf` wins when a schema - * carries both, being the stricter of the two. + * producing a form that cannot express the call. + * + * A schema carrying **both** `oneOf` and `anyOf` is declined rather than + * half-read. The two are independent keywords a value must satisfy *together*, + * not two spellings of one union, so picking one and dropping the other builds + * a form that silently omits real constraints — worse than the empty form this + * module exists to replace, because it looks complete. Satisfying both honestly + * means offering the cross product of their alternatives, which no real schema + * has yet asked for and which produces a picker whose option labels are pairs; + * until something does, declining leaves the root `properties` rendering + * unchanged and claims nothing that is not true. */ export function resolveRootUnion( schema: T, @@ -208,6 +217,9 @@ export function resolveRootUnion( ); const base = withoutComposition(merged); + if (schema.oneOf !== undefined && schema.anyOf !== undefined) { + return { base, branches: [] }; + } const members = schema.oneOf ?? schema.anyOf ?? []; const branches = members.map(toBranch); if ( From 84cd0c10f0ecb9182e9ddd4117f27823e9b7bda2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 01:41:17 -0400 Subject: [PATCH 014/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=202=20=E2=80=94=20conjunctive=20merges,=20generated-name=20c?= =?UTF-8?q?ollisions,=20CLI=20branch=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: a name both the root and a branch declare is now MERGED rather than replaced, so a root `minimum` survives a branch `maximum`; a branch that contradicts the root on a property's `type` describes an unsatisfiable value and is declined outright. Branch offerability also requires that `properties` really is an object (a malformed `properties: null` threw) and that the member's own `type` admits objects. - rootUnion: `ownFields` becomes `declaredFields` — a branch commonly specializes a root property, and consumers must render the specialization. - TUI: the generated select and branch prefixes step aside from any name the schema itself declares (JSON has no reserved namespace), decode filters them by exact name, a branch's specialization renders in its own section, and a `const` is rendered as a one-option select whose typed value is restored from the schema on submit. - convertToolParameters: identify the branch from the discriminator constants the call supplies; when none identifies one, coerce only names every declaring branch types the same way rather than picking an arbitrary branch. Signed-off-by: cliffhall --- README.md | 6 +- clients/tui/__tests__/schemaToForm.test.ts | 112 ++++++++++-- clients/tui/src/utils/schemaToForm.ts | 178 +++++++++++++++----- clients/web/src/test/core/jsonUtils.test.ts | 146 ++++++++++++++++ clients/web/src/test/core/rootUnion.test.ts | 56 +++++- core/json/jsonUtils.ts | 117 ++++++++++--- core/json/rootUnion.ts | 127 ++++++++++++-- 7 files changed, 635 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 9890e969ca..5f78f6b394 100644 --- a/README.md +++ b/README.md @@ -304,9 +304,11 @@ The **TUI** has the same gap and is worth checking against the same server (`--t The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped. -The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. +The **CLI** has no form at all, but the same flattening decides how `--tool-arg` values are typed: a branch's `count: { "type": "number" }` is what turns `--tool-arg count=3` into `3` rather than `"3"`. Which branch is *inferred* rather than chosen — a discriminated union pins its discriminator with `const`, so the supplied arguments either identify one branch or they do not. When nothing identifies one, only the names every branch that declares them types the same way are coerced; a name one branch calls a number and another a boolean is passed through as the string it was typed as, rather than run through an arbitrary branch's schema. -Three things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing. A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this". +All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. + +Four things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — as is one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A union whose branch **contradicts** the root about a property's `type` is declined too: the two constraints are conjunctive, so `string` under a root `number` describes a value that cannot exist, and rendering either type would accept what the schema rejects. (A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`.) A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this". #### Unportable tool schemas diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index cf5afb8ce4..3a7fd4563a 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - decodeFormValues, - schemaToForm, - VARIANT_FIELD, -} from "../src/utils/schemaToForm.js"; +import { decodeFormValues, schemaToForm } from "../src/utils/schemaToForm.js"; describe("schemaToForm", () => { it("returns an empty Parameters section when there is no schema", () => { @@ -445,7 +441,7 @@ describe("schemaToForm", () => { "sms", ]); expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ - VARIANT_FIELD, + "__variant", "note", ]); }); @@ -468,7 +464,7 @@ describe("schemaToForm", () => { it("offers a variant select listing the alternatives", () => { const form = schemaToForm(UNION, "union_tool"); expect(form.sections[0]!.fields[0]).toMatchObject({ - name: VARIANT_FIELD, + name: "__variant", type: "select", initialValue: "0", options: [ @@ -493,15 +489,17 @@ describe("schemaToForm", () => { } }); - it("seeds a branch's discriminator const so it need not be typed", () => { + it("renders a const as a one-option select so it cannot be changed", () => { const form = schemaToForm(UNION, "union_tool"); expect(form.sections[1]!.fields[0]).toMatchObject({ name: "__b0__kind", + type: "select", initialValue: "email", + options: [{ label: "email", value: "email" }], }); }); - it("prefers a const over a conflicting default", () => { + it("renders a const outside a union the same way", () => { const form = schemaToForm( { type: "object", @@ -509,14 +507,62 @@ describe("schemaToForm", () => { }, "const_tool", ); - expect(form.sections[0]!.fields[0]).toMatchObject({ initialValue: "a" }); + expect(form.sections[0]!.fields[0]).toMatchObject({ + type: "select", + initialValue: "a", + options: [{ label: "a", value: "a" }], + }); + }); + + it("renders a branch's specialization of a root property in its section", () => { + const form = schemaToForm( + { + type: "object", + properties: { count: {} }, + anyOf: [ + { type: "object", properties: { count: { type: "integer" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }, + "specializing", + ); + // The untyped base declaration is not rendered a second time as a string. + expect(form.sections[0]!.fields.map((field) => field.name)).toEqual([ + "__variant", + ]); + expect(form.sections[1]!.fields[0]).toMatchObject({ + name: "__b0__count", + type: "integer", + }); + }); + + it("keeps its generated names clear of the schema's own", () => { + const form = schemaToForm( + { + type: "object", + properties: { __variant: { type: "string" } }, + anyOf: [ + { type: "object", properties: { __b0__x: { type: "string" } } }, + { type: "object", properties: { y: { type: "string" } } }, + ], + }, + "colliding", + ); + const names = form.sections.flatMap((section) => + section.fields.map((field) => field.name), + ); + // The select steps aside for the declared `__variant`, and the branch + // prefix steps aside for the declared `__b0__x`. + expect(names).toContain("__variant_"); + expect(names).toContain("__variant"); + expect(names).toContain("__b_0____b0__x"); }); describe("decodeFormValues", () => { it("submits the chosen branch's fields under their real names", () => { expect( decodeFormValues(UNION, { - [VARIANT_FIELD]: "0", + __variant: "0", note: "hi", __b0__kind: "email", __b0__address: "a@b.c", @@ -529,7 +575,7 @@ describe("schemaToForm", () => { it("drops the branches the call is not making", () => { expect( decodeFormValues(UNION, { - [VARIANT_FIELD]: "1", + __variant: "1", __b0__kind: "email", __b0__address: "a@b.c", __b1__kind: "sms", @@ -541,7 +587,7 @@ describe("schemaToForm", () => { it("omits a branch field the user never filled", () => { expect( decodeFormValues(UNION, { - [VARIANT_FIELD]: "0", + __variant: "0", __b0__kind: "email", }), ).toEqual({ kind: "email" }); @@ -550,7 +596,7 @@ describe("schemaToForm", () => { it("falls back to the first branch on an unusable selection", () => { expect( decodeFormValues(UNION, { - [VARIANT_FIELD]: "nonsense", + __variant: "nonsense", __b0__kind: "email", }), ).toEqual({ kind: "email" }); @@ -562,6 +608,44 @@ describe("schemaToForm", () => { values, ); }); + + it("restores a const from the schema rather than trusting the form", () => { + // ink-form has no immutable field, and a select hands back a string — + // so the pinned value is re-applied on the way out, with its own type. + expect( + decodeFormValues( + { + type: "object", + anyOf: [ + { + type: "object", + properties: { n: { const: 7 }, a: { type: "string" } }, + }, + { type: "object", properties: { n: { const: 8 } } }, + ], + }, + { __variant: "0", __b0__n: "tampered", __b0__a: "x" }, + ), + ).toEqual({ n: 7, a: "x" }); + }); + + it("keeps a base argument whose name looks generated", () => { + const schema = { + type: "object", + properties: { __b0__x: { type: "string" } }, + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }; + expect( + decodeFormValues(schema, { + __variant: "0", + __b0__x: "mine", + __b_0__a: "chosen", + }), + ).toEqual({ __b0__x: "mine", a: "chosen" }); + }); }); it("merges a root allOf into the parameters section", () => { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 2558dc315a..99cff40e16 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -80,11 +80,46 @@ interface JsonSchemaObject { * submit the second branch's discriminator. Prefixing each branch's fields and * choosing between them explicitly is what makes the alternatives independent. */ -export const VARIANT_FIELD = "__variant"; +/** + * The generated field names a root-union form uses, chosen so they cannot + * collide with a property the schema itself declares. + * + * JSON object property names have no reserved namespace: a server may declare + * an argument called `__variant`, or one starting with `__b`. A fixed prefix + * would then either be shadowed by that argument or would silently swallow it + * on the way out, so both names are extended with `_` until nothing declared + * can be confused with them. Derived from the schema alone, so + * {@link schemaToForm} and {@link decodeFormValues} compute the same names + * without passing anything between them. + */ +function generatedNames( + base: { properties?: Record }, + branches: { declaredFields: string[] }[], +): { variant: string; prefix: string } { + const declared = [ + ...Object.keys(base.properties ?? {}), + ...branches.flatMap((branch) => branch.declaredFields), + ]; + let variant = "__variant"; + while (declared.includes(variant)) variant += "_"; + let prefix = "__b"; + while (declared.some((name) => name.startsWith(prefix))) prefix += "_"; + return { variant, prefix }; +} /** The form-local name a branch's field is rendered under. */ -function branchFieldName(branchIndex: number, name: string): string { - return `__b${branchIndex}__${name}`; +function branchFieldName( + prefix: string, + branchIndex: number, + name: string, +): string { + return `${prefix}${branchIndex}__${name}`; +} + +/** The `const` a property schema pins its value to, if any. */ +function constOf(schema: unknown): unknown { + if (typeof schema !== "object" || schema === null) return undefined; + return (schema as { const?: unknown }).const; } /** @@ -105,14 +140,35 @@ export function schemaToForm( // could only be called with empty arguments. const { base, branches } = resolveRootUnion(schema); - const parameters = buildFields(base); - if (branches.length > 0) { - // ink-form is static, so there is no picker that can swap the fields out. - // Every branch is rendered instead, and this select says which one the - // call means — read back by {@link decodeFormValues}, which drops the rest. - parameters.unshift({ + if (branches.length === 0) { + return { + title, + sections: [{ title: "Parameters", fields: buildFields(base) }], + }; + } + + const { variant, prefix } = generatedNames(base, branches); + const branchDeclared = new Set( + branches.flatMap((branch) => branch.declaredFields), + ); + + // The base section renders what the base *alone* declares. A property a + // branch also declares is rendered in that branch's section instead, showing + // the branch's specialization — root `count: {}` under branch + // `count: { type: "number" }` is a number field there, not a string here. + const baseProperties = Object.fromEntries( + Object.entries(base.properties ?? {}).filter( + ([name]) => !branchDeclared.has(name), + ), + ); + + // ink-form is static, so there is no picker that can swap the fields out. + // Every branch is rendered instead, and this select says which one the call + // means — read back by `decodeFormValues`, which drops the rest. + const parameters: FormField[] = [ + { type: "select", - name: VARIANT_FIELD, + name: variant, label: "Variant", required: true, initialValue: "0", @@ -120,8 +176,9 @@ export function schemaToForm( label: branch.label, value: String(index), })), - } as FormField); - } + } as FormField, + ...buildFields({ properties: baseProperties, required: base.required }), + ]; const sections: FormSection[] = [{ title: "Parameters", fields: parameters }]; @@ -129,15 +186,15 @@ export function schemaToForm( // says: only one alternative applies to a call, so requiring them would build // a form that can never be submitted. branches.forEach((branch, index) => { - const ownProperties = Object.fromEntries( - branch.ownFields.map((name) => [ - branchFieldName(index, name), + const properties = Object.fromEntries( + branch.declaredFields.map((name) => [ + branchFieldName(prefix, index, name), branch.schema.properties?.[name], ]), ); sections.push({ title: branch.label, - fields: buildFields({ properties: ownProperties }), + fields: buildFields({ properties }), }); }); @@ -146,45 +203,75 @@ export function schemaToForm( /** * Turn what the form submitted back into the arguments the server expects: - * the base fields, plus the fields of the branch the {@link VARIANT_FIELD} - * select names, under their real property names. + * the base fields, plus the fields of the branch the variant select names, + * under their real property names. * * Every other branch's fields are dropped rather than sent — they describe a - * shape this call is not making, and the user filled at most one section. Call - * this on the way out of the form; for a schema with no root union it returns - * the values unchanged, so it is safe to apply unconditionally. + * shape this call is not making, and the user filled at most one section. The + * generated names are filtered by exact match rather than by prefix, so an + * argument the server really named `__b0__x` survives. + * + * A `const` is re-applied from the schema rather than taken from the form. + * ink-form has no immutable field, so a discriminator is rendered as a + * one-option select and its value is restored here regardless — which also + * keeps a non-string constant's type, since a select hands back a string. + * + * Call this on the way out of the form; for a schema with no root union it + * returns the values unchanged, so it is safe to apply unconditionally. */ export function decodeFormValues( schema: JsonSchemaObject | null | undefined, values: Record, ): Record { - const { branches } = resolveRootUnion(schema ?? {}); + const { base, branches } = resolveRootUnion(schema ?? {}); if (branches.length === 0) { - return values; + return applyConstants(base.properties ?? {}, values); } - const raw = values[VARIANT_FIELD]; - const selected = Number(raw); + const { variant, prefix } = generatedNames(base, branches); + const selected = Number(values[variant]); const branchIndex = Number.isInteger(selected) && selected >= 0 && selected < branches.length ? selected : 0; + const branch = branches[branchIndex]!; + + const generated = new Set([variant]); + branches.forEach((each, index) => { + for (const name of each.declaredFields) { + generated.add(branchFieldName(prefix, index, name)); + } + }); const decoded: Record = {}; for (const [name, value] of Object.entries(values)) { - // Skip the select itself and every branch's prefixed field; the chosen - // branch's are re-added below under the names the schema declares. - if (name !== VARIANT_FIELD && !name.startsWith("__b")) { + if (!generated.has(name)) { decoded[name] = value; } } - for (const name of branches[branchIndex]!.ownFields) { - const value = values[branchFieldName(branchIndex, name)]; + for (const name of branch.declaredFields) { + const value = values[branchFieldName(prefix, branchIndex, name)]; if (value !== undefined) { decoded[name] = value; } } - return decoded; + return applyConstants(branch.schema.properties ?? {}, decoded); +} + +/** Overwrite every `const`-pinned field with the value its schema fixes. */ +function applyConstants( + properties: Record, + values: Record, +): Record { + const pinned = Object.entries(properties).filter( + ([, schema]) => constOf(schema) !== undefined, + ); + if (pinned.length === 0) return values; + const result = { ...values }; + for (const [name, schema] of pinned) { + result[name] = constOf(schema) as T; + } + return result; } /** Build the ink-form fields for one already-flattened object schema. */ @@ -215,6 +302,21 @@ function buildFields(schema: JsonSchemaObject): FormField[] { let field: FormField; + // A `const` admits exactly one value, and ink-form has no read-only field — + // so it is rendered as a select with that single option, which the user + // cannot change (#2123). `decodeFormValues` restores the schema's own typed + // value on submit, since a select hands back a string. + const pinned = property.const; + if (pinned !== undefined) { + fields.push({ + type: "select", + ...baseField, + initialValue: String(pinned), + options: [{ label: String(pinned), value: String(pinned) }], + } as FormField); + continue; + } + // Handle enum -> select. Detect the array-of-enums case on `items.enum` // alone (matching the web SchemaForm guard) — a standard array-of-enums // schema carries no top-level `enum`, so gating on it would drop the field @@ -279,17 +381,11 @@ function buildFields(schema: JsonSchemaObject): FormField[] { } // Set initial value from default (ink-form FormField allows initialValue for some types). - // A `const` is seeded the same way and OUTRANKS `default`: it is a - // one-value enumeration, so the only submittable value is already known and - // the user would otherwise have to hand-type a union's discriminator - // (#2123), while `default` is an annotation a schema may set to something - // its own `const` rejects. Tested against `undefined` rather than `??` - // chained, so an explicit `null` default is honored as a value. - const initialValue = - property.const !== undefined ? property.const : property.default; - if (initialValue !== undefined) { + // A `const` never reaches here — it was rendered as its own one-option + // select above, which is also why `default` needs no precedence rule. + if (property.default !== undefined) { (field as FormField & { initialValue?: unknown }).initialValue = - initialValue; + property.default; } fields.push(field); diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 522c0bbd0e..fea4a72590 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -123,6 +123,152 @@ describe("JSON Utils", () => { }); }); + it("picks the branch its discriminator names (#2123)", () => { + const discriminated: Tool = { + name: "discriminated", + inputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "a" }, + value: { type: "number" }, + }, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "b" }, + value: { type: "boolean" }, + }, + }, + ], + }, + }; + // `value` is a number in one branch and a boolean in the other, so the + // discriminator is the only thing that says how to coerce it. + expect( + convertToolParameters(discriminated, { kind: "b", value: "true" }), + ).toEqual({ kind: "b", value: true }); + expect( + convertToolParameters(discriminated, { kind: "a", value: "3" }), + ).toEqual({ kind: "a", value: 3 }); + }); + + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { + const ambiguous: Tool = { + name: "ambiguous", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + value: { type: "number" }, + a: { type: "string" }, + }, + }, + { + type: "object", + properties: { + value: { type: "boolean" }, + b: { type: "string" }, + }, + }, + ], + }, + }; + // Nothing identifies a branch, so coercing `value` by an arbitrary one + // would turn `true` into `Number("true")` — `NaN`, i.e. `null` on the + // wire. The raw string is honest; it is also what shipped before. + expect(convertToolParameters(ambiguous, { value: "true" })).toEqual({ + value: "true", + }); + }); + + it("agrees on an array-form type across branches (#2123)", () => { + const arrayTyped: Tool = { + name: "array-typed", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { v: { type: ["number", "null"] }, a: {} }, + }, + { + type: "object", + properties: { v: { type: ["number", "null"] }, b: {} }, + }, + ], + }, + }; + // Both spell the type the same way, so it is not ambiguous. + expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: "2" }); + }); + + it("ignores a malformed branch declaration when matching constants (#2123)", () => { + const malformed: Tool = { + name: "malformed", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "a" }, + broken: null as unknown, + value: { type: "number" }, + }, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "b" }, + value: { type: "boolean" }, + }, + }, + ], + }, + }; + // A `properties: { broken: null }` entry must not throw, and a branch is + // still identifiable by the discriminator that *is* well-formed. + expect( + convertToolParameters(malformed, { kind: "a", value: "3" }), + ).toEqual({ kind: "a", value: 3 }); + }); + + it("falls back to the branch-agreement path when no constant is supplied (#2123)", () => { + const discriminated: Tool = { + name: "no-discriminator-supplied", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "a" }, + shared: { type: "number" }, + }, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "b" }, + shared: { type: "number" }, + }, + }, + ], + }, + }; + // Both branches match vacuously, so no single branch is identified — but + // they agree about `shared`, so it is still coerced. + expect(convertToolParameters(discriminated, { shared: "4" })).toEqual({ + shared: 4, + }); + }); + it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { const allOfTool: Tool = { name: "allof-tool", diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 21419d5108..16713bad81 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -50,8 +50,8 @@ describe("resolveRootUnion", () => { "address", ]); expect(branches[0].schema.required).toEqual(["note", "kind", "address"]); - expect(branches[0].ownFields).toEqual(["kind", "address"]); - expect(branches[1].ownFields).toEqual(["kind", "phone"]); + expect(branches[0].declaredFields).toEqual(["kind", "address"]); + expect(branches[1].declaredFields).toEqual(["kind", "phone"]); }); it("strips the composition keywords it has absorbed", () => { @@ -106,16 +106,40 @@ describe("resolveRootUnion", () => { ); }); - it("lets a branch's declaration win a name collision with the root", () => { + it("merges a name collision rather than replacing the root's declaration", () => { + // Both apply, so the root's floor must survive the branch's ceiling. const { branches } = resolveRootUnion({ type: "object", - properties: { id: { type: "string" } }, + properties: { id: { type: "number", minimum: 0 } }, required: ["id"], - anyOf: [{ type: "object", properties: { id: { type: "number" } } }, SMS], + anyOf: [ + { type: "object", properties: { id: { maximum: 10 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches[0].schema.properties?.id).toEqual({ + type: "number", + minimum: 0, + maximum: 10, }); - expect(branches[0].schema.properties?.id).toEqual({ type: "number" }); // `required` unions rather than duplicating. expect(branches[0].schema.required).toEqual(["id"]); + // The branch declares the name, even though the base did too. + expect(branches[0].declaredFields).toEqual(["id"]); + }); + + it("declines a union whose branch contradicts the root's type for a field", () => { + // `string` under a base `number` describes a value that cannot exist, so + // flattening it would render one type and accept what the schema rejects. + const { branches } = resolveRootUnion({ + type: "object", + properties: { id: { type: "number" } }, + anyOf: [ + { type: "object", properties: { id: { type: "string" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toEqual([]); }); describe("branch labels", () => { @@ -221,6 +245,26 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("declines a member whose type rules objects out", () => { + // Tool arguments are a JSON object, so a `{ type: "string" }` member can + // never match — a fillable form for it would offer an invalid call. + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, { type: "string", properties: { a: {} } }], + }); + expect(branches).toEqual([]); + }); + + it("declines a member whose properties are not an object", () => { + // Members arrive as `unknown`, so this is reachable and must not throw. + expect( + resolveRootUnion({ + type: "object", + anyOf: [EMAIL, { type: "object", properties: null as unknown }], + }).branches, + ).toEqual([]); + }); + it("declines an empty union", () => { expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual( [], diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index da9c51a266..27ed66904e 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -125,6 +125,94 @@ export function convertParameterValue( return value; } +/** + * Which property schema types each supplied argument, for a tool whose + * `inputSchema` puts its fields on root composition branches (#2123). + * + * Reading only the root's `properties` finds no schema for any of them, so + * every value would be sent as the string the user typed — `--tool-arg count=3` + * reaching the server as `"3"`. + * + * The CLI has no branch picker, so which branch a call means is *inferred*: + * a discriminated union pins its discriminator with `const`, and the supplied + * arguments either match one branch's constants or they do not. + * + * - **Exactly one branch matches** — use its merged schema, which is also where + * a branch's specialization of a root-declared property lives. + * - **No branch is identifiable** — coerce only the names every branch that + * declares them types the *same* way. A name two branches type differently + * is left uncoerced rather than coerced by an arbitrary branch: `value` as a + * number in branch 0 and a boolean in branch 1 would otherwise turn + * `value=true` into `Number("true")`, i.e. `NaN`. Passing the raw string + * through is what this function did for every argument before it existed. + */ +function coercionProperties( + base: { properties?: Record }, + branches: { + schema: { properties?: Record }; + declaredFields: string[]; + }[], + params: Record, +): Record { + if (branches.length === 0) { + return { ...base.properties }; + } + + const matching = branches.filter((branch) => + matchesConstants(branch.schema.properties ?? {}, params), + ); + if (matching.length === 1) { + return { ...matching[0].schema.properties }; + } + + const properties: Record = { ...base.properties }; + for (const name of new Set(branches.flatMap((b) => b.declaredFields))) { + // Only the branches that *declare* the name have an opinion about it — a + // branch that merely inherited the root's declaration is not a second, + // disagreeing vote. Read through the merged schema so a branch's + // specialization of a root property carries the root's keywords too. + const declarations = branches + .filter((branch) => branch.declaredFields.includes(name)) + .map((branch) => branch.schema.properties?.[name]); + const types = new Set(declarations.map((schema) => typeNameOf(schema))); + if (types.size === 1) { + properties[name] = declarations[0]; + } else { + delete properties[name]; + } + } + return properties; +} + +/** A schema's `type`, as a comparable string (`""` when it states none). */ +function typeNameOf(schema: unknown): string { + if (typeof schema !== "object" || schema === null) return ""; + const { type } = schema as { type?: unknown }; + return Array.isArray(type) + ? type.join(",") + : typeof type === "string" + ? type + : ""; +} + +/** + * Whether every `const`-pinned property of a branch agrees with what was + * supplied. Values arrive as strings, so the comparison is stringified — which + * is exactly right for a discriminator, whose constants are string literals. + */ +function matchesConstants( + properties: Record, + params: Record, +): boolean { + return Object.entries(properties).every(([name, schema]) => { + if (typeof schema !== "object" || schema === null) return true; + const constValue = (schema as { const?: unknown }).const; + if (constValue === undefined) return true; + const supplied = params[name]; + return supplied === undefined || supplied === String(constValue); + }); +} + /** * Convert string parameters to JSON values based on tool schema */ @@ -134,33 +222,10 @@ export function convertToolParameters( ): Record { const result: Record = {}; // A property's schema can live on a root composition branch rather than on - // the root itself (#2123). Reading only the root's `properties` there finds - // no schema for any argument, so every value would be sent as the string the - // user typed — `--tool-arg count=3` reaching the server as `"3"`. The union - // is flattened by merging every branch, because the CLI has no branch - // selection to consult: an argument named by one branch is coerced by that - // branch's schema, and a name two branches type differently keeps the first, - // which is no worse than the untyped passthrough it replaces. + // the root itself (#2123); see `coercionProperties` for how the branch is + // identified when it does. const { base, branches } = resolveRootUnion(tool.inputSchema ?? {}); - // Start from the FIRST branch's merged schema rather than from the base: a - // branch may *specialize* a property the root also declares (root - // `count: {}`, branch `count: { type: "number" }`), and merging branch-last - // is what gives the typed declaration — starting from the base would keep the - // untyped one and send `count=3` as `"3"`, the very coercion this restores. - // Later branches then contribute only names not seen yet, so first-branch - // precedence matches what the web form seeds and what a name two branches - // type differently resolves to. - const properties: Record = { - ...(branches[0]?.schema.properties ?? base.properties), - }; - for (const branch of branches.slice(1)) { - for (const [name, schema] of Object.entries( - branch.schema.properties ?? {}, - )) { - properties[name] ??= schema; - } - } - + const properties = coercionProperties(base, branches, params); for (const [key, value] of Object.entries(params)) { const paramSchema = properties[key] as ParameterSchema | undefined; diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 88f4c0081c..dd731907ec 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -64,8 +64,13 @@ export interface RootUnionBranch { schema: ResolvedSchema; /** Human-readable name for the picker — see {@link branchLabel}. */ label: string; - /** Names this branch contributes that the base does not. */ - ownFields: string[]; + /** + * The names **this branch itself declares** — including one the base also + * declares, since a branch commonly *specializes* a root property (root + * `count: {}`, branch `count: { type: "number" }`) and a renderer that showed + * only the base's version would render the untyped one. + */ + declaredFields: string[]; } /** What {@link resolveRootUnion} decomposes a root schema into. */ @@ -92,34 +97,120 @@ function toBranch(value: unknown): RootUnionSchema | null { return value as RootUnionSchema; } +/** Whether a schema's `type` permits an object instance. */ +function admitsObject(schema: RootUnionSchema): boolean { + const { type } = schema; + if (type === undefined) return true; + return Array.isArray(type) ? type.includes("object") : type === "object"; +} + +/** A readable `properties` map, or `null` when the value is not one. */ +function propertiesOf(schema: RootUnionSchema): Record | null { + const { properties } = schema; + if ( + typeof properties !== "object" || + properties === null || + Array.isArray(properties) + ) { + return null; + } + return properties; +} + /** - * Whether a branch contributes anything a form can render. + * Whether a branch is one a form can offer as an alternative. + * + * Three ways it is not, all of which would put an option in the picker that + * cannot be filled in: * - * A `{ type: "null" }` member — the nullable encoding {@link - * ./nullableUnion.ts} owns — and a `$ref`-only or empty branch carry no - * properties, so offering them as alternatives would produce a picker whose - * options render nothing. + * - **It carries no fields.** A `{ type: "null" }` member — the nullable + * encoding {@link ./nullableUnion.ts} owns — and a `$ref`-only or empty + * branch render nothing. + * - **`properties` is not an object.** Members arrive as `unknown`, so a + * malformed `properties: null` is reachable and would throw in `Object.keys` + * rather than being declined. + * - **Its `type` rules objects out.** Tool arguments are a JSON object, so a + * `{ type: "string", properties: {…} }` member can never match — rendering it + * as a fillable form would offer a call that cannot be valid. */ -function hasFields(branch: RootUnionSchema): boolean { +function isOfferable(branch: RootUnionSchema): boolean { + const properties = propertiesOf(branch); return ( - branch.properties !== undefined && Object.keys(branch.properties).length > 0 + properties !== null && + Object.keys(properties).length > 0 && + admitsObject(branch) ); } +/** + * Merge two declarations of the same property name. + * + * Where a base and a branch both name a property, JSON Schema applies **both** + * — so keeping only the branch's would silently drop the base's constraints + * (root `{ minimum: 0 }` plus branch `{ maximum: 10 }` must keep the floor). + * A shallow union preserves every keyword only one side states, with the + * branch winning a keyword both state, being the more specific declaration. + * + * A *contradiction* is not resolvable this way and is not resolved here — see + * {@link contradicts}, which declines such a union outright. + */ +function mergeProperty( + baseProperty: unknown, + branchProperty: unknown, +): unknown { + const a = toBranch(baseProperty); + const b = toBranch(branchProperty); + if (a === null || b === null) { + return branchProperty; + } + return { ...a, ...b }; +} + +/** + * Whether a branch states a property `type` its base declaration rules out. + * + * The two are conjunctive, so `string` under a base `number` describes a value + * that cannot exist. No form can express that, and flattening it would render + * one of the two types and accept values the schema rejects — so the caller + * declines the union instead of picking a side. + */ +function contradicts(base: RootUnionSchema, branch: RootUnionSchema): boolean { + const baseProperties = propertiesOf(base) ?? {}; + const branchProperties = propertiesOf(branch) ?? {}; + return Object.entries(branchProperties).some(([name, branchProperty]) => { + const a = toBranch(baseProperties[name]); + const b = toBranch(branchProperty); + if (a === null || b === null) return false; + return ( + typeof a.type === "string" && + typeof b.type === "string" && + a.type !== b.type + ); + }); +} + /** * Merge a composition branch's `properties` and `required` into a base schema. * * Both keywords are **conjunctive** where they meet: a value satisfying an * `allOf` branch satisfies the base *and* the branch, and a value matching a * union branch must satisfy the root's own constraints too. So properties union - * (branch wins a name collision, being the more specific declaration) and - * `required` unions. + * — a name both declare merged through {@link mergeProperty} rather than + * replaced — and `required` unions. */ function mergeBranch( base: T, branch: RootUnionSchema, ): ResolvedSchema { - const properties = { ...base.properties, ...branch.properties }; + const baseProperties = propertiesOf(base) ?? {}; + const branchProperties = propertiesOf(branch) ?? {}; + const properties: Record = { ...baseProperties }; + for (const [name, branchProperty] of Object.entries(branchProperties)) { + properties[name] = + name in baseProperties + ? mergeProperty(baseProperties[name], branchProperty) + : branchProperty; + } const required = [ ...(base.required ?? []), ...(branch.required ?? []).filter( @@ -165,7 +256,7 @@ function branchLabel( if (typeof branch.title === "string" && branch.title.trim() !== "") { return branch.title; } - const properties = branch.properties ?? {}; + const properties = propertiesOf(branch) ?? {}; const constOf = (name: string): string | null => { const property = toBranch(properties[name]) as { const?: unknown } | null; const value = property?.const; @@ -224,13 +315,15 @@ export function resolveRootUnion( const branches = members.map(toBranch); if ( branches.length === 0 || - branches.some((branch) => branch === null || !hasFields(branch)) + branches.some( + (branch) => + branch === null || !isOfferable(branch) || contradicts(base, branch), + ) ) { return { base, branches: [] }; } const discriminatorProperty = schema.discriminator?.propertyName; - const baseFields = new Set(Object.keys(base.properties ?? {})); return { base, branches: branches @@ -240,9 +333,7 @@ export function resolveRootUnion( // `properties`/`required` off the branch, so the merge stays that way. schema: mergeBranch(base, branch), label: branchLabel(branch, index, discriminatorProperty), - ownFields: Object.keys(branch.properties ?? {}).filter( - (name) => !baseFields.has(name), - ), + declaredFields: Object.keys(propertiesOf(branch) ?? {}), })), }; } From 1a271e49313ba93258bf5b6f431610cbc606558b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 02:01:44 -0400 Subject: [PATCH 015/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=203=20=E2=80=94=20conjunctive=20keywords,=20unflattenable=20?= =?UTF-8?q?allOf,=20branch=20inference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: a branch restating a constraint the root states differently is declined (keywords at one level are conjunctive and no intersection is computed here), while annotations may disagree freely. An `allOf` with a member it cannot fold in — JSON Schema's boolean form, or a $ref whose constraints are unknown rather than absent — is left intact instead of merged-and-stripped, which would turn an unsatisfiable schema into a fillable form. - rootUnion: `declaresAnyFields` counts the raw composition members, so `hasInputFields` reports fields for a union the resolver declines and an App tool is never auto-invoked with empty arguments. - SchemaForm: the picker opens on the branch the supplied values identify (an App deep link overlays its args, so they can name another branch), and a branch switch no longer carries a value the outgoing branch declared — the branches may type the same name differently. - TUI: a base property some branch redeclares is offered in EVERY branch's section under that branch's inherited or specialized declaration, so a branch that does not redeclare it can still supply a required root argument. Signed-off-by: cliffhall --- AGENTS.md | 20 ++- README.md | 10 +- clients/tui/__tests__/schemaToForm.test.ts | 26 +++ clients/tui/src/utils/schemaToForm.ts | 51 ++++-- .../groups/SchemaForm/SchemaForm.test.tsx | 49 ++++++ .../groups/SchemaForm/SchemaForm.tsx | 58 ++++++- clients/web/src/test/core/rootUnion.test.ts | 103 +++++++++++- clients/web/src/utils/toolUtils.test.ts | 16 ++ clients/web/src/utils/toolUtils.ts | 21 +-- core/json/rootUnion.ts | 157 ++++++++++++++---- 10 files changed, 442 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd9ef2451b..00b8180977 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,12 +206,20 @@ v2/main/ │ │ # field; schemaToForm.decodeFormValues translates │ │ # back on submit), and convertToolParameters (which │ │ # branch's schema types a CLI --tool-arg). -│ │ # DECLINES rather than half-reads: a union whose -│ │ # members are not ALL field-carrying objects, and a -│ │ # schema carrying BOTH oneOf and anyOf (independent -│ │ # keywords, satisfied together — picking one drops -│ │ # real constraints). Does not interpret `not` at -│ │ # all — #2123; +│ │ # DECLINES rather than half-reads, since keywords at +│ │ # one level are CONJUNCTIVE and it computes no +│ │ # intersections: a union whose members are not ALL +│ │ # field-carrying objects (or whose member `type` +│ │ # rules objects out), a branch restating a +│ │ # constraint the root states differently, an allOf +│ │ # carrying a member it cannot fold in (`false`, a +│ │ # $ref) — dropping the keyword there would turn an +│ │ # UNSATISFIABLE schema into a fillable form — and a +│ │ # schema carrying BOTH oneOf and anyOf. Does not +│ │ # interpret `not` at all. Declining changes what +│ │ # RENDERS, never whether the tool takes arguments: +│ │ # declaresAnyFields counts the raw members, so an +│ │ # App tool is never auto-invoked with `{}` — #2123; │ │ # schemaLint.ts: tool-schema PORTABILITY lint — │ │ # constructs that are legal JSON Schema and are │ │ # refused or mishandled by real MCP clients (a bare diff --git a/README.md b/README.md index 5f78f6b394..463e0ad72b 100644 --- a/README.md +++ b/README.md @@ -308,7 +308,15 @@ The **CLI** has no form at all, but the same flattening decides how `--tool-arg` All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render. -Four things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — as is one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A union whose branch **contradicts** the root about a property's `type` is declined too: the two constraints are conjunctive, so `string` under a root `number` describes a value that cannot exist, and rendering either type would accept what the schema rejects. (A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`.) A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this". +What it declines to flatten is as deliberate as what it flattens, and every case falls back to whatever the schema's own `properties` describe rather than claiming something untrue: + +- **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. +- **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all. +- **An `allOf` with a member it cannot fold in** — JSON Schema's boolean form (`allOf: [false, …]` admits nothing) or a `$ref`, whose constraints are unknown rather than absent. Merging the rest and dropping the keyword would turn an unsatisfiable schema into a fillable form. +- **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. +- **`not`**, which is not interpreted at all: there is no faithful form for "anything except this". + +Declining changes what *renders*, never whether the tool is treated as taking arguments: a declined union still has fields, so an App tool carrying one still asks for them rather than auto-invoking with `{}`. #### Unportable tool schemas diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 3a7fd4563a..ddd61e8134 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -536,6 +536,32 @@ describe("schemaToForm", () => { }); }); + it("offers a shared base property in every branch's section", () => { + const schema = { + type: "object", + properties: { count: {} }, + required: ["count"], + anyOf: [ + { type: "object", properties: { count: { type: "integer" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }; + const form = schemaToForm(schema, "shared"); + // Rendered only in branch A's section, branch B could never supply the + // required root argument — the chosen branch's fields are what decode. + expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([ + "__b1__other", + "__b1__count", + ]); + expect( + decodeFormValues(schema, { + __variant: "1", + __b1__other: "x", + __b1__count: "4", + }), + ).toEqual({ other: "x", count: "4" }); + }); + it("keeps its generated names clear of the schema's own", () => { const form = schemaToForm( { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 99cff40e16..4bc2fadb40 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -116,6 +116,32 @@ function branchFieldName( return `${prefix}${branchIndex}__${name}`; } +/** + * Base-declared names that at least one branch also declares. They move out of + * the base section and into every branch's, so the branch showing is the one + * whose declaration renders — and so a branch that does not specialize the name + * still offers it rather than losing a root argument it must supply. + */ +function sharedFieldNames( + base: { properties?: Record }, + branches: { declaredFields: string[] }[], +): string[] { + const declared = new Set(branches.flatMap((branch) => branch.declaredFields)); + return Object.keys(base.properties ?? {}).filter((name) => + declared.has(name), + ); +} + +/** The property names one branch's section renders, under prefixed names. */ +function branchFields( + base: { properties?: Record }, + branches: { declaredFields: string[] }[], + index: number, +): string[] { + const own = branches[index]?.declaredFields ?? []; + return [...new Set([...own, ...sharedFieldNames(base, branches)])]; +} + /** The `const` a property schema pins its value to, if any. */ function constOf(schema: unknown): unknown { if (typeof schema !== "object" || schema === null) return undefined; @@ -148,17 +174,18 @@ export function schemaToForm( } const { variant, prefix } = generatedNames(base, branches); - const branchDeclared = new Set( - branches.flatMap((branch) => branch.declaredFields), - ); + const shared = sharedFieldNames(base, branches); - // The base section renders what the base *alone* declares. A property a - // branch also declares is rendered in that branch's section instead, showing - // the branch's specialization — root `count: {}` under branch - // `count: { type: "number" }` is a number field there, not a string here. + // The base section renders what the base *alone* declares. A property some + // branch also declares moves into every branch's section, so the branch's + // specialization is what renders there — root `count: {}` under branch + // `count: { type: "number" }` is a number field, not a string — while a + // branch that does not specialize it still offers it, under its own inherited + // declaration. Rendering it once in the base section instead would strand it: + // the chosen branch's decoded fields are what reach the call. const baseProperties = Object.fromEntries( Object.entries(base.properties ?? {}).filter( - ([name]) => !branchDeclared.has(name), + ([name]) => !shared.includes(name), ), ); @@ -187,7 +214,7 @@ export function schemaToForm( // a form that can never be submitted. branches.forEach((branch, index) => { const properties = Object.fromEntries( - branch.declaredFields.map((name) => [ + branchFields(base, branches, index).map((name) => [ branchFieldName(prefix, index, name), branch.schema.properties?.[name], ]), @@ -237,8 +264,8 @@ export function decodeFormValues( const branch = branches[branchIndex]!; const generated = new Set([variant]); - branches.forEach((each, index) => { - for (const name of each.declaredFields) { + branches.forEach((_each, index) => { + for (const name of branchFields(base, branches, index)) { generated.add(branchFieldName(prefix, index, name)); } }); @@ -249,7 +276,7 @@ export function decodeFormValues( decoded[name] = value; } } - for (const name of branch.declaredFields) { + for (const name of branchFields(base, branches, branchIndex)) { const value = values[branchFieldName(prefix, branchIndex, name)]; if (value !== undefined) { decoded[name] = value; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index a35189d89d..3c0e6f7a71 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2100,6 +2100,55 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(mode.value).toBe("fast"); }); + it("opens on the branch the supplied values identify", () => { + // A deep link overlays its args on the initial defaults, so values for + // one branch can arrive while the picker would otherwise open on another. + renderWithMantine( + , + ); + expect( + (screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement) + .value, + ).toBe("sms"); + expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy(); + }); + + it("does not carry a value the outgoing branch declared", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + anyOf: [ + { + type: "object", + title: "A", + properties: { value: { type: "number", title: "Value" } }, + }, + { + type: "object", + title: "B", + properties: { value: { type: "boolean", title: "Value" } }, + }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "B" })); + // Branch B types `value` as a boolean; carrying the 3 would check the box + // and submit a number the branch rejects. + expect(onChange).toHaveBeenCalledWith({}); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 6d4578efd9..bb1625765e 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -571,6 +571,36 @@ function resolveValue( return getDefaultValue(fieldSchema); } +/** + * The branch a set of values already identifies, or `null` when they identify + * none uniquely. + * + * A discriminated union pins its discriminator with `const`, so values carrying + * one name the branch they belong to. This matters because the form is not + * always mounted empty: an App deep link overlays `appArgs` on the initial + * defaults, so `{ kind: "sms", … }` can arrive while the picker would otherwise + * open on the first branch — showing one shape's controls while a differently + * shaped set of values sits underneath, ready to be submitted (#2123). + */ +function matchBranchIndex( + branches: { schema: InspectorFormSchema }[], + values: Record, +): number | null { + const matches: number[] = []; + branches.forEach((branch, index) => { + const pinned = Object.entries(branch.schema.properties ?? {}).filter( + ([, fieldSchema]) => fieldSchema.const !== undefined, + ); + if ( + pinned.length > 0 && + pinned.every(([name, fieldSchema]) => values[name] === fieldSchema.const) + ) { + matches.push(index); + } + }); + return matches.length === 1 ? matches[0] : null; +} + export function SchemaForm({ schema, values, @@ -588,7 +618,9 @@ export function SchemaForm({ // Which alternative the form is currently showing. Held here because it is a // property of this rendering, not of the arguments: `values` carries what the // user typed, and nothing in it names a branch. - const [branchIndex, setBranchIndex] = useState(0); + const [branchIndex, setBranchIndex] = useState( + () => matchBranchIndex(branches, values) ?? 0, + ); // A form reused for another entity can be handed a shorter union, so the // index is clamped rather than trusted — `resetKey` resets it below, but a // caller that omits it (the elicitation panels mount fresh) supplies none. @@ -622,8 +654,10 @@ export function SchemaForm({ useValueChange(resetKey, () => { setEnlargedFields(new Set()); // Which branch is selected belongs to the entity it was chosen for, for the - // same reason enlargement does. - setBranchIndex(0); + // same reason enlargement does — reset to whichever branch the new values + // identify, so the visible selection cannot disagree with what would be + // submitted, and to the first when they identify none. + setBranchIndex(matchBranchIndex(branches, values) ?? 0); }); // Stable so a field's reporting effect subscribes once, not per render. The @@ -687,13 +721,21 @@ export function SchemaForm({ if (!nextBranch) return; setBranchIndex(nextIndex); const nextProperties = nextBranch.schema.properties ?? {}; + // Only what the *base* contributed is carried across — a value the outgoing + // branch declared belongs to that branch's shape, and a name the two + // branches type differently would arrive as the wrong type entirely (a `3` + // typed into branch A's number field landing in branch B's checkbox). A + // field the incoming branch pins to a `const` is likewise not carried: the + // branches of a discriminated union share the discriminator's *name* and + // disagree about its value. + const outgoing = new Set(activeBranch?.declaredFields ?? []); const carried: Record = {}; for (const [name, fieldSchema] of Object.entries(nextProperties)) { - // A field the incoming branch pins to a `const` is not carried: the two - // branches of a discriminated union share the discriminator's *name* and - // disagree about its value, so keeping what the outgoing branch put - // there would leave the arguments claiming the shape they no longer have. - if (values[name] !== undefined && fieldSchema.const === undefined) { + if ( + values[name] !== undefined && + fieldSchema.const === undefined && + !outgoing.has(name) + ) { carried[name] = values[name]; } } diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 16713bad81..9e5f320094 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "vitest"; -import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; +import { + declaresAnyFields, + resolveRootUnion, +} from "@inspector/core/json/rootUnion.js"; const EMAIL = { type: "object", @@ -76,6 +79,43 @@ describe("resolveRootUnion", () => { expect(Object.keys(base.properties ?? {})).toEqual(["note"]); }); + it("leaves an unsatisfiable allOf alone rather than dropping it", () => { + // `allOf: [false, …]` admits nothing. Treating the boolean member as a + // no-op and stripping the keyword would render a fillable form for a schema + // that can never be satisfied. + const schema = { + type: "object" as const, + allOf: [ + false as unknown, + { type: "object", properties: { x: { type: "string" } } }, + ], + }; + const { base, branches } = resolveRootUnion(schema); + expect(branches).toEqual([]); + expect(base.properties).toBeUndefined(); + expect(base.allOf).toBe(schema.allOf); + }); + + it("leaves an allOf carrying a $ref alone", () => { + // The referent is not resolved here, so its constraints are unknown rather + // than absent. + const { base } = resolveRootUnion({ + type: "object", + allOf: [{ $ref: "#/$defs/Thing" }], + }); + expect(base.allOf).toHaveLength(1); + expect(base.properties).toBeUndefined(); + }); + + it("declines a union when the allOf beneath it could not be flattened", () => { + const { branches } = resolveRootUnion({ + type: "object", + allOf: [{ $ref: "#/$defs/Thing" }], + anyOf: [EMAIL, SMS], + }); + expect(branches).toEqual([]); + }); + it("merges allOf branches unconditionally", () => { const { base, branches } = resolveRootUnion({ type: "object", @@ -128,6 +168,40 @@ describe("resolveRootUnion", () => { expect(branches[0].declaredFields).toEqual(["id"]); }); + it("declines a union whose branch restates a constraint differently", () => { + // Both apply, so root `minimum: 10` under branch `minimum: 0` is still 10. + // Rendering either side would accept a value the schema rejects. + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { type: "number", minimum: 10 } }, + anyOf: [ + { type: "object", properties: { x: { minimum: 0 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toEqual([]); + }); + + it("tolerates a branch disagreeing only about annotations", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { type: "number", description: "root" } }, + anyOf: [ + { + type: "object", + properties: { x: { description: "branch", maximum: 3 } }, + }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toHaveLength(2); + expect(branches[0].schema.properties?.x).toEqual({ + type: "number", + description: "branch", + maximum: 3, + }); + }); + it("declines a union whose branch contradicts the root's type for a field", () => { // `string` under a base `number` describes a value that cannot exist, so // flattening it would render one type and accept what the schema rejects. @@ -280,4 +354,31 @@ describe("resolveRootUnion", () => { expect(Object.keys(base.properties ?? {})).toEqual(["a"]); }); }); + + describe("declaresAnyFields", () => { + it("sees fields on a union the resolver declines", () => { + // A declined union still HAS fields — reporting none would auto-invoke an + // App tool with `{}` instead of asking for them. + const schema = { + type: "object" as const, + anyOf: [EMAIL, { $ref: "#/$defs/SMS" }], + }; + expect(resolveRootUnion(schema).branches).toEqual([]); + expect(declaresAnyFields(schema)).toBe(true); + }); + + it("sees fields nested a level down", () => { + expect( + declaresAnyFields({ + type: "object", + allOf: [{ type: "object", anyOf: [EMAIL, SMS] }], + }), + ).toBe(true); + }); + + it("reports none for a bare object schema", () => { + expect(declaresAnyFields({ type: "object" })).toBe(false); + expect(declaresAnyFields(undefined)).toBe(false); + }); + }); }); diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index 9c8c962381..a524ae9751 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -118,6 +118,22 @@ describe("hasInputFields with root composition (#2123)", () => { ).toBe(true); }); + it("sees fields on a union the form declines to flatten", () => { + // Otherwise an App tool with such a schema is treated as input-free and + // invoked immediately with `{}`. + expect( + hasInputFields( + tool({ + type: "object", + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { $ref: "#/$defs/Other" }, + ], + }), + ), + ).toBe(true); + }); + it("still reports no fields for a bare object schema", () => { expect(hasInputFields(tool({ type: "object" }))).toBe(false); }); diff --git a/clients/web/src/utils/toolUtils.ts b/clients/web/src/utils/toolUtils.ts index e253ef0e9c..710df00052 100644 --- a/clients/web/src/utils/toolUtils.ts +++ b/clients/web/src/utils/toolUtils.ts @@ -1,5 +1,5 @@ import type { Tool } from "@modelcontextprotocol/client"; -import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; +import { declaresAnyFields } from "@inspector/core/json/rootUnion.js"; /** * Returns the display label for an MCP entity that follows the BaseMetadata @@ -17,19 +17,16 @@ export function resolveDisplayLabel(name: string, title?: string): string { * one place so the definition of "has fields" stays consistent if it ever * grows to consider `additionalProperties` etc. * - * Root composition is resolved first, since a schema declaring its fields on a - * root `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with - * such a schema would otherwise launch with empty arguments rather than asking - * for them. + * Root composition counts, since a schema declaring its fields on a root + * `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with such a + * schema would otherwise launch with empty arguments rather than asking for + * them. Counted from the composition members directly rather than from a + * resolved union, so a schema whose composition the form declines to flatten + * still reports the fields it has: it renders fewer controls, not none, and + * auto-invoking it would be wrong either way. */ export function hasInputFields(tool: Tool): boolean { - const { base, branches } = resolveRootUnion(tool.inputSchema); - return ( - Object.keys(base.properties ?? {}).length > 0 || - branches.some( - (branch) => Object.keys(branch.schema.properties ?? {}).length > 0, - ) - ); + return declaresAnyFields(tool.inputSchema); } /** diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index dd731907ec..469df7a1e1 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -42,6 +42,11 @@ export interface RootUnionSchema { * own repro does). Read only to *label* a branch — never to validate. */ discriminator?: { propertyName?: string }; + /** + * Read only to *decline* a member: its referent is not resolved here, so a + * `$ref` member's constraints are unknown rather than absent. + */ + $ref?: string; } /** @@ -143,16 +148,59 @@ function isOfferable(branch: RootUnionSchema): boolean { } /** - * Merge two declarations of the same property name. + * Keywords that annotate rather than constrain. Two declarations may disagree + * about these without describing different values, so a disagreement here is + * not a reason to refuse to flatten. + */ +const ANNOTATION_KEYWORDS = new Set([ + "title", + "description", + "examples", + "deprecated", + "readOnly", + "writeOnly", + "$comment", +]); + +/** Structural equality, via canonical JSON — enough for schema keyword values. */ +function sameValue(a: unknown, b: unknown): boolean { + return a === b || JSON.stringify(a) === JSON.stringify(b); +} + +/** + * Whether two declarations of one property name disagree about a constraint. * * Where a base and a branch both name a property, JSON Schema applies **both** - * — so keeping only the branch's would silently drop the base's constraints - * (root `{ minimum: 0 }` plus branch `{ maximum: 10 }` must keep the floor). - * A shallow union preserves every keyword only one side states, with the - * branch winning a keyword both state, being the more specific declaration. - * - * A *contradiction* is not resolvable this way and is not resolved here — see - * {@link contradicts}, which declines such a union outright. + * — the value must satisfy the two together. A keyword only one side states is + * therefore safe to carry across, but a keyword they state *differently* is a + * conjunction this module cannot compute: root `minimum: 10` under branch + * `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable at all, + * and `type: "string"` under `type: "number"` describes a value that cannot + * exist. Taking either side would render a form that accepts what the schema + * rejects, so the caller declines the composition instead. + */ +function conflicts(baseProperty: unknown, branchProperty: unknown): boolean { + const a = toBranch(baseProperty); + const b = toBranch(branchProperty); + if (a === null || b === null) { + return ( + baseProperty !== undefined && !sameValue(baseProperty, branchProperty) + ); + } + const left = a as Record; + const right = b as Record; + return Object.keys(right).some( + (keyword) => + !ANNOTATION_KEYWORDS.has(keyword) && + keyword in left && + !sameValue(left[keyword], right[keyword]), + ); +} + +/** + * Merge two declarations of the same property name — a shallow union, which is + * the whole conjunction once {@link conflicts} has ruled out a keyword the two + * state differently. */ function mergeProperty( baseProperty: unknown, @@ -167,25 +215,57 @@ function mergeProperty( } /** - * Whether a branch states a property `type` its base declaration rules out. + * Whether a member can be folded into a base schema at all. + * + * Two ways it cannot, both of which would have the composition keyword + * *removed* while its constraint went unapplied — a form that submits fields + * the schema forbids: * - * The two are conjunctive, so `string` under a base `number` describes a value - * that cannot exist. No form can express that, and flattening it would render - * one of the two types and accept values the schema rejects — so the caller - * declines the union instead of picking a side. + * - **It is not an object schema.** JSON Schema's boolean form is legal, and + * `allOf: [false, …]` is unsatisfiable, so silently treating a non-object + * member as a no-op turns "nothing is valid here" into a fillable form. + * - **It is a `$ref`.** The referent is not resolved by this module, so its + * constraints are unknown rather than absent. */ -function contradicts(base: RootUnionSchema, branch: RootUnionSchema): boolean { +function isFlattenable(member: unknown): boolean { + const branch = toBranch(member); + return branch !== null && branch.$ref === undefined; +} + +/** Whether any property declaration of `branch` conflicts with the base's. */ +function conflictsWithBase( + base: RootUnionSchema, + branch: RootUnionSchema, +): boolean { const baseProperties = propertiesOf(base) ?? {}; const branchProperties = propertiesOf(branch) ?? {}; - return Object.entries(branchProperties).some(([name, branchProperty]) => { - const a = toBranch(baseProperties[name]); - const b = toBranch(branchProperty); - if (a === null || b === null) return false; - return ( - typeof a.type === "string" && - typeof b.type === "string" && - a.type !== b.type - ); + return Object.entries(branchProperties).some( + ([name, branchProperty]) => + name in baseProperties && conflicts(baseProperties[name], branchProperty), + ); +} + +/** + * Every property name the schema's composition members declare, whether or not + * the composition could be flattened. + * + * A caller deciding whether a tool takes arguments at all must count these: a + * union this module declines still *has* fields, and reporting "no fields" + * would auto-invoke the tool with `{}` rather than asking for them. + */ +export function declaresAnyFields( + schema: RootUnionSchema | undefined, +): boolean { + if (schema === undefined) return false; + if (Object.keys(propertiesOf(schema) ?? {}).length > 0) return true; + const members = [ + ...(schema.allOf ?? []), + ...(schema.anyOf ?? []), + ...(schema.oneOf ?? []), + ]; + return members.some((member) => { + const branch = toBranch(member); + return branch !== null && declaresAnyFields(branch); }); } @@ -299,11 +379,28 @@ function branchLabel( export function resolveRootUnion( schema: T, ): ResolvedRootUnion { - const merged = (schema.allOf ?? []).reduce>( - (acc, member) => { - const branch = toBranch(member); - return branch === null ? acc : mergeBranch(acc, branch); - }, + // `allOf` is only folded in when EVERY member can be. A member this module + // cannot flatten would otherwise have its keyword stripped by + // `withoutComposition` while its constraint went unapplied — so an + // unsatisfiable `allOf: [false, …]` would render as a fillable form, and a + // `$ref` member's constraints would read as absent rather than unknown. When + // one cannot, nothing is flattened: the schema's own `properties` render, its + // composition keywords stay on it, and no union is offered either, since a + // branch would otherwise be merged against a base whose constraints are not + // all known. + const allOfMembers = schema.allOf ?? []; + const flattenable = + allOfMembers.every(isFlattenable) && + allOfMembers.every( + (member) => + !conflictsWithBase(schema, toBranch(member) as RootUnionSchema), + ); + if (!flattenable) { + return { base: schema as ResolvedSchema, branches: [] }; + } + + const merged = allOfMembers.reduce>( + (acc, member) => mergeBranch(acc, toBranch(member) as RootUnionSchema), schema as ResolvedSchema, ); const base = withoutComposition(merged); @@ -317,7 +414,9 @@ export function resolveRootUnion( branches.length === 0 || branches.some( (branch) => - branch === null || !isOfferable(branch) || contradicts(base, branch), + branch === null || + !isOfferable(branch) || + conflictsWithBase(base, branch), ) ) { return { base, branches: [] }; From d8e9e502be2648826116720e14354a4bc01b7200 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 02:09:44 -0400 Subject: [PATCH 016/213] test: cover the non-object property-declaration paths in rootUnion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's coverage gate failed on `core/json/rootUnion.ts` branches (89.81%). A property declared with JSON Schema's boolean form has no keywords to merge, so the two sides agree only by being the same declaration — now tested both ways, and the dead `baseProperty !== undefined` guard is gone (`conflicts` is only reached for a name the base declares). Signed-off-by: cliffhall --- clients/web/src/test/core/rootUnion.test.ts | 27 +++++++++++++++++++++ core/json/rootUnion.ts | 8 +++--- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 9e5f320094..21794afd76 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -202,6 +202,33 @@ describe("resolveRootUnion", () => { }); }); + it("carries an identical non-object declaration across", () => { + // JSON Schema's boolean form is legal as a property schema. There are no + // keywords to merge, so the two agree only by being the same declaration. + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: true as unknown }, + anyOf: [ + { type: "object", properties: { x: true as unknown } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toHaveLength(2); + expect(branches[0].schema.properties?.x).toBe(true); + }); + + it("declines a union whose branch redeclares a non-object property differently", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: true as unknown }, + anyOf: [ + { type: "object", properties: { x: false as unknown } }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toEqual([]); + }); + it("declines a union whose branch contradicts the root's type for a field", () => { // `string` under a base `number` describes a value that cannot exist, so // flattening it would render one type and accept what the schema rejects. diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 469df7a1e1..52fd029a8e 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -183,9 +183,11 @@ function conflicts(baseProperty: unknown, branchProperty: unknown): boolean { const a = toBranch(baseProperty); const b = toBranch(branchProperty); if (a === null || b === null) { - return ( - baseProperty !== undefined && !sameValue(baseProperty, branchProperty) - ); + // At least one side is not a readable schema object — JSON Schema's boolean + // form, or something malformed. Nothing can be merged keyword-wise, so the + // two agree only if they are the same declaration. (Only reached for a name + // the base declares, so `baseProperty` is never simply absent here.) + return !sameValue(baseProperty, branchProperty); } const left = a as Record; const right = b as Record; From 7e4af5ed34f8b58403e3398f99f6f9982f3c5b30 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 02:24:17 -0400 Subject: [PATCH 017/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=204=20=E2=80=94=20allOf=20faithfulness,=20const=20null,=20br?= =?UTF-8?q?anch-aware=20seeding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: an `allOf` member is foldable only when it states nothing beyond what the merge applies (`type`/`properties`/`required` plus annotations), so a nested `anyOf`, a `not`, or an `additionalProperties` can no longer be erased along with the keyword; members are checked against the ACCUMULATED merge, so two of them contradicting each other is caught even when neither contradicts the root. - admitsNull: `const: null` admits null, so a required field pinned to it is no longer seeded with the one value it accepts and then reported missing. - collectSchemaDefaults takes the values a caller is about to overlay, and seeds the branch THEY identify — the App deep link passes its `appArgs`, so branch 0's defaults can no longer sit invisibly under branch 1's arguments. - The branch inference moves into core as `selectBranchIndex`, shared by the seeding and the form's picker so the two cannot disagree. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.tsx | 39 ++-------- .../views/InspectorView/InspectorView.tsx | 10 ++- clients/web/src/test/core/rootUnion.test.ts | 68 +++++++++++++++++ clients/web/src/utils/jsonUtils.test.ts | 22 ++++++ clients/web/src/utils/jsonUtils.ts | 18 ++++- core/json/nullableUnion.ts | 7 ++ core/json/rootUnion.ts | 75 +++++++++++++++---- 7 files changed, 188 insertions(+), 51 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index bb1625765e..5974fa4c5d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -31,7 +31,10 @@ import { isStringEnum, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; -import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; +import { + resolveRootUnion, + selectBranchIndex, +} from "@inspector/core/json/rootUnion.js"; import { collectSchemaDefaults } from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ @@ -571,36 +574,6 @@ function resolveValue( return getDefaultValue(fieldSchema); } -/** - * The branch a set of values already identifies, or `null` when they identify - * none uniquely. - * - * A discriminated union pins its discriminator with `const`, so values carrying - * one name the branch they belong to. This matters because the form is not - * always mounted empty: an App deep link overlays `appArgs` on the initial - * defaults, so `{ kind: "sms", … }` can arrive while the picker would otherwise - * open on the first branch — showing one shape's controls while a differently - * shaped set of values sits underneath, ready to be submitted (#2123). - */ -function matchBranchIndex( - branches: { schema: InspectorFormSchema }[], - values: Record, -): number | null { - const matches: number[] = []; - branches.forEach((branch, index) => { - const pinned = Object.entries(branch.schema.properties ?? {}).filter( - ([, fieldSchema]) => fieldSchema.const !== undefined, - ); - if ( - pinned.length > 0 && - pinned.every(([name, fieldSchema]) => values[name] === fieldSchema.const) - ) { - matches.push(index); - } - }); - return matches.length === 1 ? matches[0] : null; -} - export function SchemaForm({ schema, values, @@ -619,7 +592,7 @@ export function SchemaForm({ // property of this rendering, not of the arguments: `values` carries what the // user typed, and nothing in it names a branch. const [branchIndex, setBranchIndex] = useState( - () => matchBranchIndex(branches, values) ?? 0, + () => selectBranchIndex(branches, values) ?? 0, ); // A form reused for another entity can be handed a shorter union, so the // index is clamped rather than trusted — `resetKey` resets it below, but a @@ -657,7 +630,7 @@ export function SchemaForm({ // same reason enlargement does — reset to whichever branch the new values // identify, so the visible selection cannot disagree with what would be // submitted, and to the first when they identify none. - setBranchIndex(matchBranchIndex(branches, values) ?? 0); + setBranchIndex(selectBranchIndex(branches, values) ?? 0); }); // Stable so a field's reporting effect subscribes once, not per render. The diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index fef3d470d8..74e2f8033c 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1035,8 +1035,16 @@ export function InspectorView({ // value is absent from `formValues`, the schema-form's validity check // fails, and Open App is silently disabled — an automated driver's click // then no-ops and the iframe-wait spins forever. + // The args are passed to the seeding too, not just overlaid on it: for a + // schema whose arguments are a root union they can name a branch other + // than the first, and defaults seeded from the wrong branch would sit in + // the submitted arguments where the form — showing the branch the args + // identify — never displays them (#2123). const formValues = { - ...collectSchemaDefaults(toFormSchema(target.inputSchema) ?? {}), + ...collectSchemaDefaults( + toFormSchema(target.inputSchema) ?? {}, + deepLink.appArgs ?? {}, + ), ...deepLink.appArgs, }; // Seed the selection directly rather than routing through diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 21794afd76..e1a9acf7e8 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { declaresAnyFields, resolveRootUnion, + selectBranchIndex, } from "@inspector/core/json/rootUnion.js"; const EMAIL = { @@ -116,6 +117,36 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("leaves an allOf member carrying a constraint the merge cannot apply", () => { + // Only `properties`/`required` are merged, so a member stating anything + // further would have that constraint erased with the keyword. + for (const member of [ + { type: "object", properties: { x: {} }, additionalProperties: false }, + { type: "object", properties: { x: {} }, not: { properties: {} } }, + { type: "string", properties: { x: {} } }, + ]) { + const { base } = resolveRootUnion({ + type: "object" as const, + allOf: [member as unknown], + }); + expect(base.allOf).toHaveLength(1); + expect(base.properties).toBeUndefined(); + } + }); + + it("declines an allOf whose members contradict each other", () => { + // Neither conflicts with the root, which declares no `x` at all. + const { base } = resolveRootUnion({ + type: "object", + allOf: [ + { type: "object", properties: { x: { minimum: 10 } } }, + { type: "object", properties: { x: { minimum: 0 } } }, + ], + }); + expect(base.allOf).toHaveLength(2); + expect(base.properties).toBeUndefined(); + }); + it("merges allOf branches unconditionally", () => { const { base, branches } = resolveRootUnion({ type: "object", @@ -408,4 +439,41 @@ describe("resolveRootUnion", () => { expect(declaresAnyFields(undefined)).toBe(false); }); }); + + describe("selectBranchIndex", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, SMS], + }); + + it("names the branch whose discriminator the values carry", () => { + expect(selectBranchIndex(branches, { kind: "sms" })).toBe(1); + }); + + it("reports none when the values identify nothing", () => { + expect(selectBranchIndex(branches, {})).toBeNull(); + expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); + }); + + it("reports none when two branches match", () => { + // An ambiguous answer is worse than none: the caller falls back to the + // first branch, where the picker and the values at least agree. + const ambiguous = resolveRootUnion({ + type: "object", + anyOf: [EMAIL, { ...EMAIL, properties: { ...EMAIL.properties } }], + }).branches; + expect(selectBranchIndex(ambiguous, { kind: "email" })).toBeNull(); + }); + + it("reports none for a branch that pins nothing", () => { + const unpinned = resolveRootUnion({ + type: "object", + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }).branches; + expect(selectBranchIndex(unpinned, { a: "x" })).toBeNull(); + }); + }); }); diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index f788b5dd2d..7f3c1a0c12 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -410,6 +410,28 @@ describe("root composition (#2123)", () => { ).toEqual({ merged: "m" }); }); + it("seeds the branch the known values identify, not the first", () => { + // What the App deep link does: seed defaults, then overlay its `appArgs`. + // Seeding branch 0 underneath branch 1's args would leave `address` in the + // submitted arguments, invisible to a form showing the SMS branch. + expect(collectSchemaDefaults(UNION, { kind: "sms" })).toEqual({ + kind: "sms", + }); + }); + + it("accepts a required field pinned to null", () => { + // `const: null` admits null and nothing else, so seeding it must not leave + // submit disabled on a value the user cannot change. + const schema: InspectorFormSchema = { + type: "object", + properties: { nothing: { const: null } }, + required: ["nothing"], + }; + const values = collectSchemaDefaults(schema); + expect(values).toEqual({ nothing: null }); + expect(hasMissingRequiredFields(schema, values)).toBe(false); + }); + it("blocks submission while no branch is satisfied", () => { expect(hasMissingRequiredFields(UNION, {})).toBe(true); expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 0e29195eaf..c078c03605 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -2,7 +2,10 @@ import { admitsNull, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; -import { resolveRootUnion } from "@inspector/core/json/rootUnion.js"; +import { + resolveRootUnion, + selectBranchIndex, +} from "@inspector/core/json/rootUnion.js"; export type JsonValue = | string @@ -124,14 +127,23 @@ export function getDataType(value: JsonValue): DataType { */ export function collectSchemaDefaults( schema: InspectorFormSchema, + knownValues: Record = {}, ): Record { // Seed from the shape the form actually renders: root `allOf` merged in, and - // for a root union the branch the picker starts on (#2123). Seeding every + // for a root union the branch the picker will open on (#2123). Seeding every // branch would put fields of shapes the call is not making into the // arguments; seeding none would leave the branch's defaults — its // discriminator `const` among them — displayed but never submitted. + // + // `knownValues` is for a caller that already holds arguments it is about to + // overlay on these defaults, as the App deep link does with its `appArgs`. + // Those values can name a branch other than the first through its + // discriminator, and seeding the first branch's defaults underneath them + // would leave another shape's fields in the submitted arguments, invisible + // to a form showing the branch the values actually identify. const { base, branches } = resolveRootUnion(schema); - const properties = (branches[0]?.schema ?? base).properties ?? {}; + const selected = selectBranchIndex(branches, knownValues) ?? 0; + const properties = (branches[selected]?.schema ?? base).properties ?? {}; const result: Record = {}; for (const [fieldName, rawSchema] of Object.entries(properties)) { // Collapse a nullable union first, for the same reason `SchemaForm` does: diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index e4cc8b2412..9dad6f8ec3 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -372,6 +372,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { return false; } + // `const: null` admits null and nothing else, whatever the schema says (or + // omits) about `type`. Without this a required field pinned to null is seeded + // with the only value it accepts and then reported as missing, leaving submit + // permanently disabled on a form the user cannot change (#2123). + if (schema.const === null) { + return true; + } // 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 diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 52fd029a8e..fff5c7c737 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -229,9 +229,20 @@ function mergeProperty( * - **It is a `$ref`.** The referent is not resolved by this module, so its * constraints are unknown rather than absent. */ +const MERGEABLE_KEYWORDS = new Set(["type", "properties", "required"]); + function isFlattenable(member: unknown): boolean { const branch = toBranch(member); - return branch !== null && branch.$ref === undefined; + if (branch === null || !admitsObject(branch)) return false; + // `mergeBranch` applies `properties` and `required` and nothing else, so a + // member stating anything further would have that constraint erased along + // with the keyword — a nested `anyOf`, a `not`, an `additionalProperties`, + // a `$ref` whose referent is not resolved here. Only the keywords the merge + // actually carries, plus annotations that constrain nothing, are accepted. + return Object.keys(branch).every( + (keyword) => + MERGEABLE_KEYWORDS.has(keyword) || ANNOTATION_KEYWORDS.has(keyword), + ); } /** Whether any property declaration of `branch` conflicts with the base's. */ @@ -391,20 +402,21 @@ export function resolveRootUnion( // branch would otherwise be merged against a base whose constraints are not // all known. const allOfMembers = schema.allOf ?? []; - const flattenable = - allOfMembers.every(isFlattenable) && - allOfMembers.every( - (member) => - !conflictsWithBase(schema, toBranch(member) as RootUnionSchema), - ); - if (!flattenable) { - return { base: schema as ResolvedSchema, branches: [] }; + let merged = schema as ResolvedSchema; + for (const member of allOfMembers) { + const branch = toBranch(member); + // Each member is checked against what has been merged **so far**, not + // against the original root: two members declaring `x.minimum` as 10 and 0 + // agree with a root that declares neither, while contradicting each other. + if ( + branch === null || + !isFlattenable(member) || + conflictsWithBase(merged, branch) + ) { + return { base: schema as ResolvedSchema, branches: [] }; + } + merged = mergeBranch(merged, branch); } - - const merged = allOfMembers.reduce>( - (acc, member) => mergeBranch(acc, toBranch(member) as RootUnionSchema), - schema as ResolvedSchema, - ); const base = withoutComposition(merged); if (schema.oneOf !== undefined && schema.anyOf !== undefined) { @@ -438,3 +450,38 @@ export function resolveRootUnion( })), }; } + +/** + * The index of the branch a set of values already identifies, or `null` when + * they identify none uniquely. + * + * A discriminated union pins its discriminator with `const`, so values carrying + * one name the branch they belong to. Shared because more than one caller has + * to reach the same answer: the web form opens its picker on that branch, and + * the defaults seeded before the values are overlaid must belong to the same + * one, or the arguments carry a shape the picker is not showing. + */ +export function selectBranchIndex( + branches: RootUnionBranch[], + values: Record, +): number | null { + const matches: number[] = []; + branches.forEach((branch, index) => { + const pinned = Object.entries(propertiesOf(branch.schema) ?? {}) + .map( + ([name, schema]) => + [ + name, + (toBranch(schema) as { const?: unknown } | null)?.const, + ] as const, + ) + .filter(([, constValue]) => constValue !== undefined); + if ( + pinned.length > 0 && + pinned.every(([name, constValue]) => values[name] === constValue) + ) { + matches.push(index); + } + }); + return matches.length === 1 ? matches[0] : null; +} From f556f8831b979d63fc2bf567cec51d21e8daa5b7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 02:38:14 -0400 Subject: [PATCH 018/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=205=20=E2=80=94=20union=20member=20faithfulness,=20partial?= =?UTF-8?q?=20discriminators,=20const-null=20siblings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: union members get the same flattenability test as allOf members, so a branch whose nested `allOf: [false]` makes it unsatisfiable is no longer offered as a callable alternative with that constraint erased. - selectBranchIndex: weigh only the pinned constants the caller SUPPLIED. One it did not supply is a value this identification exists to seed, so requiring it meant a deep link naming `kind` alone matched no branch whenever the branches also pinned, say, a `version`. A supplied constant that disagrees is still evidence against. - admitsNull: `const: null` is conjunctive with its siblings, not an override — `{ type: "string", const: null }` and `{ const: null, anyOf: [...] }` reject every value, so the claim is made only where the type and a sibling union leave null on the table. Signed-off-by: cliffhall --- AGENTS.md | 11 +++-- README.md | 2 +- .../web/src/test/core/nullableUnion.test.ts | 19 ++++++++ clients/web/src/test/core/rootUnion.test.ts | 45 +++++++++++++++++++ core/json/nullableUnion.ts | 26 ++++++++--- core/json/rootUnion.ts | 14 +++++- 6 files changed, 103 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00b8180977..9b4fa79dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,10 +211,13 @@ v2/main/ │ │ # intersections: a union whose members are not ALL │ │ # field-carrying objects (or whose member `type` │ │ # rules objects out), a branch restating a -│ │ # constraint the root states differently, an allOf -│ │ # carrying a member it cannot fold in (`false`, a -│ │ # $ref) — dropping the keyword there would turn an -│ │ # UNSATISFIABLE schema into a fillable form — and a +│ │ # constraint the root states differently, ANY member +│ │ # stating more than the merge applies (only +│ │ # type/properties/required — a `false`, a $ref, a +│ │ # nested applicator would have its constraint erased +│ │ # with the keyword, turning an UNSATISFIABLE schema +│ │ # into a fillable form; allOf members are checked +│ │ # against the ACCUMULATED merge, not the root), and a │ │ # schema carrying BOTH oneOf and anyOf. Does not │ │ # interpret `not` at all. Declining changes what │ │ # RENDERS, never whether the tool takes arguments: diff --git a/README.md b/README.md index 463e0ad72b..1b34448f7d 100644 --- a/README.md +++ b/README.md @@ -312,7 +312,7 @@ What it declines to flatten is as deliberate as what it flattens, and every case - **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. - **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all. -- **An `allOf` with a member it cannot fold in** — JSON Schema's boolean form (`allOf: [false, …]` admits nothing) or a `$ref`, whose constraints are unknown rather than absent. Merging the rest and dropping the keyword would turn an unsatisfiable schema into a fillable form. +- **A composition member stating anything the merge cannot apply.** Only `type`, `properties` and `required` are folded in, so a member carrying a nested `allOf`/`anyOf`, a `not`, an `additionalProperties`, or a `$ref` would have that constraint erased along with the keyword — turning an unsatisfiable schema (`allOf: [false, …]` admits nothing) into a fillable form. `allOf` members are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root. - **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. - **`not`**, which is not interpreted at all: there is no faithful form for "anything except this". diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index eb22ab2e54..4b44aa0229 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -731,4 +731,23 @@ describe("admitsNull", () => { }), ).toBe(true); }); + + describe("a const-null schema (#2123)", () => { + it("admits null when nothing else rules it out", () => { + expect(admitsNull({ const: null })).toBe(true); + expect(admitsNull({ type: "null", const: null })).toBe(true); + expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true); + }); + + it("does not override a sibling that rejects null", () => { + // `const` is conjunctive with its siblings, not an override: both of + // these reject every value, so claiming nullability would let the + // required-field gate accept a `null` the schema forbids. + expect(admitsNull({ type: "string", const: null })).toBe(false); + expect(admitsNull({ const: null, anyOf: [{ type: "string" }] })).toBe( + false, + ); + expect(admitsNull({ const: null, not: { type: "null" } })).toBe(false); + }); + }); }); diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index e1a9acf7e8..8a3f2a83af 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -397,6 +397,24 @@ describe("resolveRootUnion", () => { ).toEqual([]); }); + it("declines a member carrying a constraint the merge cannot apply", () => { + // Same faithfulness test the `allOf` fold applies — otherwise a branch + // whose nested `allOf: [false]` makes it unsatisfiable is offered as a + // callable alternative. + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + EMAIL, + { + type: "object", + properties: { x: { type: "string" } }, + allOf: [false as unknown], + }, + ], + }); + expect(branches).toEqual([]); + }); + it("declines an empty union", () => { expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual( [], @@ -450,6 +468,33 @@ describe("resolveRootUnion", () => { expect(selectBranchIndex(branches, { kind: "sms" })).toBe(1); }); + it("identifies a branch from the constants that were supplied", () => { + // Both branches pin `version` as well; a deep link naming only `kind` + // must still find its branch — the unsupplied constant is one this + // identification exists to seed. + const versioned = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { + version: { const: "1" }, + kind: { const: "email" }, + }, + }, + { + type: "object", + properties: { version: { const: "1" }, kind: { const: "sms" } }, + }, + ], + }).branches; + expect(selectBranchIndex(versioned, { kind: "sms" })).toBe(1); + // A supplied constant that disagrees is still evidence against. + expect(selectBranchIndex(versioned, { version: "2", kind: "sms" })).toBe( + null, + ); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 9dad6f8ec3..580c0e8fac 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -368,17 +368,15 @@ function collapsed( * an optimistic guess, and a schema this module cannot read renders through the * JSON editor with its constraints intact. */ +/** Whether an absent or null-naming `type` leaves null on the table. */ +function typeAdmitsNull(type: string | string[] | undefined): boolean { + return type === undefined || typeNamesNull(type); +} + export function admitsNull(schema: NullableUnionSchema): boolean { if (nullExcludedBySiblings(schema)) { return false; } - // `const: null` admits null and nothing else, whatever the schema says (or - // omits) about `type`. Without this a required field pinned to null is seeded - // with the only value it accepts and then reported as missing, leaving submit - // permanently disabled on a form the user cannot change (#2123). - if (schema.const === null) { - return true; - } // 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 @@ -388,6 +386,20 @@ export function admitsNull(schema: NullableUnionSchema): boolean { if (hasOpaqueApplicator(schema)) { return false; } + // `const: null` admits null and nothing else — the case a required field + // pinned to null needs, or it is seeded with the only value it accepts and + // then reported missing, leaving submit permanently disabled on a field the + // user cannot change (#2123). + // + // Claimed only where the siblings agree, since `const` is conjunctive with + // them rather than an override: `{ type: "string", const: null }` and + // `{ const: null, anyOf: [...] }` reject every value, and saying otherwise + // would let the gate accept a `null` the schema forbids. The opaque + // applicators (`not`, `allOf`, `oneOf`) are already refused above. + if (schema.const === null) { + return schema.anyOf === undefined && typeAdmitsNull(schema.type); + } + if (schema.nullable === true) { return true; } diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index fff5c7c737..75953e2d0d 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -430,6 +430,11 @@ export function resolveRootUnion( (branch) => branch === null || !isOfferable(branch) || + // The same faithfulness test the `allOf` fold applies: a member + // carrying a constraint the merge does not copy — a nested `allOf`, a + // `not`, a `$ref` — would have it erased along with the union keyword, + // which can present an unsatisfiable branch as a callable one. + !isFlattenable(branch) || conflictsWithBase(base, branch), ) ) { @@ -476,9 +481,14 @@ export function selectBranchIndex( ] as const, ) .filter(([, constValue]) => constValue !== undefined); + // Only the pinned names the caller actually supplied are evidence. A + // constant it did not supply is one this identification exists to *seed* — + // requiring it would mean a deep link naming `kind` alone matched no branch + // whenever the branches also pin, say, a `version`. + const supplied = pinned.filter(([name]) => values[name] !== undefined); if ( - pinned.length > 0 && - pinned.every(([name, constValue]) => values[name] === constValue) + supplied.length > 0 && + supplied.every(([name, constValue]) => values[name] === constValue) ) { matches.push(index); } From c15e30414f5738ba123c2f24da0e5544cf23d8b0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 02:56:55 -0400 Subject: [PATCH 019/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=206=20=E2=80=94=20additionalProperties,=20annotations,=20pro?= =?UTF-8?q?totype-safe=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: decline a union that adds fields under a restrictive root `additionalProperties`. The keyword constrains what its SIBLING `properties` does not name, so the original schema admits none of the branch fields; flattening moves them beside the keyword, where they read as allowed and the form would submit what the schema forbids. - `default` joins the annotation set: it constrains nothing, so two declarations suggesting different initial values no longer conflict. - Property records are read with `Object.hasOwn` and built with `fromEntries`/`defineProperty`. `properties` is a JSON record, so `constructor` is a legal argument name that `in` reported as a collision the root never made, and `__proto__` is one that assignment would drop into the legacy prototype setter instead of keeping as a field. - SchemaForm: a value survives a branch switch unless BOTH branches declare the name. A root argument the outgoing branch merely specialized is still a root argument in a branch that inherits it, so dropping it erased a valid value. Signed-off-by: cliffhall --- README.md | 1 + .../groups/SchemaForm/SchemaForm.test.tsx | 33 ++++++++ .../groups/SchemaForm/SchemaForm.tsx | 19 +++-- clients/web/src/test/core/rootUnion.test.ts | 77 +++++++++++++++++++ core/json/jsonUtils.ts | 14 +++- core/json/rootUnion.ts | 54 +++++++++++-- 6 files changed, 181 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1b34448f7d..1682f2a9e7 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,7 @@ What it declines to flatten is as deliberate as what it flattens, and every case - **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. - **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all. - **A composition member stating anything the merge cannot apply.** Only `type`, `properties` and `required` are folded in, so a member carrying a nested `allOf`/`anyOf`, a `not`, an `additionalProperties`, or a `$ref` would have that constraint erased along with the keyword — turning an unsatisfiable schema (`allOf: [false, …]` admits nothing) into a fillable form. `allOf` members are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root. +- **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. - **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. - **`not`**, which is not interpreted at all: there is no faithful form for "anything except this". diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 3c0e6f7a71..710deee86a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2149,6 +2149,39 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(onChange).toHaveBeenCalledWith({}); }); + it("keeps a root value the incoming branch merely inherits", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { count: {} }, + anyOf: [ + { + type: "object", + title: "A", + properties: { count: { type: "number", title: "Count" } }, + }, + { + type: "object", + title: "B", + properties: { other: { type: "string", title: "Other" } }, + }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "B" })); + // Branch B does not redeclare `count`, so it is a root argument there — + // dropping it would erase a value the schema still accepts. + expect(onChange).toHaveBeenCalledWith({ count: 3 }); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 5974fa4c5d..5b01de8d31 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -694,20 +694,23 @@ export function SchemaForm({ if (!nextBranch) return; setBranchIndex(nextIndex); const nextProperties = nextBranch.schema.properties ?? {}; - // Only what the *base* contributed is carried across — a value the outgoing - // branch declared belongs to that branch's shape, and a name the two - // branches type differently would arrive as the wrong type entirely (a `3` - // typed into branch A's number field landing in branch B's checkbox). A - // field the incoming branch pins to a `const` is likewise not carried: the - // branches of a discriminated union share the discriminator's *name* and - // disagree about its value. + // A value is carried unless it belonged to the outgoing branch's own shape: + // a name **both** branches declare may be typed differently by each, so a + // `3` typed into branch A's number field must not arrive in branch B's + // checkbox. A name only the outgoing branch specialized still survives when + // the incoming branch merely inherits the root's declaration — it is a root + // argument there, and dropping it would erase a valid value. A field the + // incoming branch pins to a `const` is never carried: the branches of a + // discriminated union share the discriminator's *name* and disagree about + // its value. const outgoing = new Set(activeBranch?.declaredFields ?? []); + const incoming = new Set(nextBranch.declaredFields); const carried: Record = {}; for (const [name, fieldSchema] of Object.entries(nextProperties)) { if ( values[name] !== undefined && fieldSchema.const === undefined && - !outgoing.has(name) + !(outgoing.has(name) && incoming.has(name)) ) { carried[name] = values[name]; } diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 8a3f2a83af..bc363652cd 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -260,6 +260,83 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("tolerates a branch suggesting a different default", () => { + // `default` constrains nothing, so two declarations suggesting different + // initial values still accept the same values. + const { branches } = resolveRootUnion({ + type: "object", + properties: { count: { default: 1 } }, + anyOf: [ + { + type: "object", + properties: { count: { type: "number", default: 2 } }, + }, + { type: "object", properties: { other: { type: "string" } } }, + ], + }); + expect(branches).toHaveLength(2); + }); + + it("does not mistake an inherited object property for a root declaration", () => { + // `constructor` is a legal argument name; finding it on `Object.prototype` + // would report a conflict the root never declared. + const anyOf: unknown[] = [ + { type: "object", properties: { constructor: { type: "string" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ]; + const { branches } = resolveRootUnion({ + type: "object", + properties: {}, + anyOf, + }); + expect(branches).toHaveLength(2); + expect(branches[0].schema.properties?.constructor).toEqual({ + type: "string", + }); + }); + + it("keeps a branch property named __proto__", () => { + // Assigning it would invoke the legacy prototype setter rather than create + // an own property, losing a renderable field. + const { branches } = resolveRootUnion({ + type: "object", + properties: { keep: { type: "string" } }, + // A computed key: `__proto__:` in an object literal is the prototype + // setter, so the literal form would not even create the property. + anyOf: [ + { type: "object", properties: { ["__proto__"]: { type: "string" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(Object.keys(branches[0].schema.properties ?? {})).toEqual([ + "keep", + "__proto__", + ]); + }); + + it("declines a union that adds fields under a restrictive additionalProperties", () => { + // `additionalProperties` constrains what its SIBLING `properties` does not + // name, so the original schema admits none of the branch fields — moving + // them beside the keyword would make them read as allowed. + const { branches } = resolveRootUnion({ + type: "object", + properties: { known: { type: "string" } }, + additionalProperties: false, + anyOf: [EMAIL, SMS], + }); + expect(branches).toEqual([]); + }); + + it("allows a restrictive additionalProperties the branches stay within", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { kind: {}, address: {}, phone: {} }, + additionalProperties: false, + anyOf: [EMAIL, SMS], + }); + expect(branches).toHaveLength(2); + }); + it("declines a union whose branch contradicts the root's type for a field", () => { // `string` under a base `number` describes a value that cannot exist, so // flattening it would render one type and accept what the schema rejects. diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 27ed66904e..32d5316629 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -165,7 +165,12 @@ function coercionProperties( return { ...matching[0].schema.properties }; } - const properties: Record = { ...base.properties }; + // `hasOwn`/`fromEntries` rather than `in`/assignment throughout: `properties` + // is a JSON record, so `constructor` and `__proto__` are legal argument names + // that the prototype chain and the legacy setter would otherwise mishandle. + const properties: Record = Object.fromEntries( + Object.entries(base.properties ?? {}), + ); for (const name of new Set(branches.flatMap((b) => b.declaredFields))) { // Only the branches that *declare* the name have an opinion about it — a // branch that merely inherited the root's declaration is not a second, @@ -176,7 +181,12 @@ function coercionProperties( .map((branch) => branch.schema.properties?.[name]); const types = new Set(declarations.map((schema) => typeNameOf(schema))); if (types.size === 1) { - properties[name] = declarations[0]; + Object.defineProperty(properties, name, { + value: declarations[0], + writable: true, + enumerable: true, + configurable: true, + }); } else { delete properties[name]; } diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 75953e2d0d..d44fcce380 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -42,6 +42,12 @@ export interface RootUnionSchema { * own repro does). Read only to *label* a branch — never to validate. */ discriminator?: { propertyName?: string }; + /** + * Read only to *decline*: `additionalProperties` constrains the names its + * **sibling** `properties` does not list, so a restrictive one at the root + * rejects every field a branch adds. See {@link resolveRootUnion}. + */ + additionalProperties?: unknown; /** * Read only to *decline* a member: its referent is not resolved here, so a * `$ref` member's constraints are unknown rather than absent. @@ -160,6 +166,9 @@ const ANNOTATION_KEYWORDS = new Set([ "readOnly", "writeOnly", "$comment", + // An annotation too: a suggested initial value constrains nothing, so two + // declarations suggesting different ones still accept the same values. + "default", ]); /** Structural equality, via canonical JSON — enough for schema keyword values. */ @@ -254,7 +263,11 @@ function conflictsWithBase( const branchProperties = propertiesOf(branch) ?? {}; return Object.entries(branchProperties).some( ([name, branchProperty]) => - name in baseProperties && conflicts(baseProperties[name], branchProperty), + // `hasOwn`, not `in`: `properties` is a JSON record, so `constructor` and + // `toString` are legal argument names that `in` would find on + // `Object.prototype` and report as collisions the root never declared. + Object.hasOwn(baseProperties, name) && + conflicts(baseProperties[name], branchProperty), ); } @@ -297,13 +310,19 @@ function mergeBranch( ): ResolvedSchema { const baseProperties = propertiesOf(base) ?? {}; const branchProperties = propertiesOf(branch) ?? {}; - const properties: Record = { ...baseProperties }; - for (const [name, branchProperty] of Object.entries(branchProperties)) { - properties[name] = - name in baseProperties + // Built through `fromEntries` rather than by assignment: a property named + // `__proto__` is a legal argument name, and assigning it would invoke the + // legacy prototype setter instead of creating an own property — losing the + // field entirely. `hasOwn` for the same reason `conflictsWithBase` uses it. + const properties: Record = Object.fromEntries([ + ...Object.entries(baseProperties), + ...Object.entries(branchProperties).map(([name, branchProperty]) => [ + name, + Object.hasOwn(baseProperties, name) ? mergeProperty(baseProperties[name], branchProperty) - : branchProperty; - } + : branchProperty, + ]), + ]); const required = [ ...(base.required ?? []), ...(branch.required ?? []).filter( @@ -441,6 +460,27 @@ export function resolveRootUnion( return { base, branches: [] }; } + // `additionalProperties` applies to whatever its **sibling** `properties` + // does not name, so a restrictive one at the root rejects every field the + // branches add — the original schema admits none of them. Flattening moves + // those fields *beside* the keyword, where they would read as allowed, so a + // form built from it would submit what the schema forbids. + const additional = base.additionalProperties; + const restrictsAdditional = + additional === false || + (typeof additional === "object" && additional !== null); + const baseNames = propertiesOf(base) ?? {}; + if ( + restrictsAdditional && + branches.some((branch) => + Object.keys(propertiesOf(branch as RootUnionSchema) ?? {}).some( + (name) => !Object.hasOwn(baseNames, name), + ), + ) + ) { + return { base, branches: [] }; + } + const discriminatorProperty = schema.discriminator?.propertyName; return { base, From 85b8e4601c34fe7aa879143bc8ed2c94e3081d65 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 03:54:24 -0400 Subject: [PATCH 020/213] fix: address Copilot review rounds 7 and 8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: a `oneOf` demands that EXACTLY one alternative match, which flattening cannot preserve, so it is offered only when a discriminator makes the alternatives mutually exclusive; `anyOf` makes no such claim and is unaffected. An empty `additionalProperties` schema is the equivalent of `true` and no longer declines a union. - selectBranchIndex: falls back to the branch whose own required fields the values supply, so an undiscriminated union's values open the picker on the shape they describe rather than on the first branch. - admitsNull: honors a sibling `nullable: true` beside `const: null`. - SchemaForm: a value is carried only where the incoming branch leaves the root's declaration as it found it; anything it declares itself is reset, since it may type the name differently from where the value was typed. `hasOwn`/`fromEntries` throughout, so `constructor` is not read as a supplied value and `__proto__` is not dropped into the prototype setter. - TUI: the same prototype-safe construction on the way out of the form, and the chosen branch's `required` list is checked at submit — rendering a branch's fields optional makes the FORM satisfiable, not the call, so the missing names are reported rather than a known-invalid call being sent. Signed-off-by: cliffhall --- AGENTS.md | 5 +- README.md | 5 +- clients/tui/__tests__/ToolTestModal.test.tsx | 75 +++++++++++ clients/tui/__tests__/schemaToForm.test.ts | 125 +++++++++++++++++- clients/tui/src/components/ToolTestModal.tsx | 23 +++- clients/tui/src/utils/schemaToForm.ts | 92 +++++++++---- .../groups/SchemaForm/SchemaForm.tsx | 31 +++-- .../web/src/test/core/nullableUnion.test.ts | 6 + clients/web/src/test/core/rootUnion.test.ts | 93 +++++++++++++ core/json/nullableUnion.ts | 5 +- core/json/rootUnion.ts | 74 ++++++++++- 11 files changed, 492 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9b4fa79dda..1e7180a1ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,10 @@ v2/main/ │ │ # SchemaForm (the Variant picker + the branch-change │ │ # value pruning), TUI schemaToForm (a section per │ │ # branch, its fields forced OPTIONAL since only one -│ │ # alternative applies — and rendered under PREFIXED +│ │ # alternative applies — the chosen branch's +│ │ # `required` is then checked at SUBMIT +│ │ # (missingRequiredFields), since optional fields make +│ │ # the FORM satisfiable, not the call — and rendered under PREFIXED │ │ # names behind a variant select, because ink-form │ │ # keys values by field name across the WHOLE form, │ │ # so two branches' `kind` would otherwise be one diff --git a/README.md b/README.md index 1682f2a9e7..c8296ebd6c 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Open the Tools tab and select `echo`. Above the fields is a **Variant** picker l Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making. -The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, preceded by a **Variant** select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. +The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, preceded by a **Variant** select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. That makes the *form* satisfiable, not the call, so the chosen branch's own `required` list is checked at submit and reported — never sent as a call already known to violate the schema. The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped. @@ -313,7 +313,8 @@ What it declines to flatten is as deliberate as what it flattens, and every case - **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. - **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all. - **A composition member stating anything the merge cannot apply.** Only `type`, `properties` and `required` are folded in, so a member carrying a nested `allOf`/`anyOf`, a `not`, an `additionalProperties`, or a `$ref` would have that constraint erased along with the keyword — turning an unsatisfiable schema (`allOf: [false, …]` admits nothing) into a fillable form. `allOf` members are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root. -- **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. +- **A `oneOf` whose alternatives are not mutually exclusive.** `oneOf` demands that *exactly one* alternative match, which flattening cannot preserve — the branches are offered as if any would do. It is only safe with a discriminator (a property every branch pins to a `const` of its own), so an undiscriminated `oneOf` is declined. `anyOf` makes no such claim and is offered either way. +- **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. An empty schema (`{}`) constrains nothing and is treated as permissive. - **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. - **`not`**, which is not interpreted at all: there is no faithful form for "anything except this". diff --git a/clients/tui/__tests__/ToolTestModal.test.tsx b/clients/tui/__tests__/ToolTestModal.test.tsx index a2c8a83848..a933bc17d0 100644 --- a/clients/tui/__tests__/ToolTestModal.test.tsx +++ b/clients/tui/__tests__/ToolTestModal.test.tsx @@ -74,6 +74,81 @@ const renderAndSubmit = async ( }; describe("ToolTestModal", () => { + it("reports a missing required argument instead of calling the tool (#2123)", async () => { + // A union branch's fields render optional — a static form cannot demand + // every branch's — so the chosen shape's own requirements are checked here. + const callTool = vi.fn(); + const tool = makeTool({ + inputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + required: ["kind", "phone"], + }, + ], + }, + } as unknown as Partial); + const api = render( + , + ); + await tick(); + setSubmitValue({ __variant: "0", __b0__kind: "email" }); + api.stdin.write("\r"); + await tick(); + await tick(); + // The assertion is the transition, not the frame — this suite drives state, + // not rendered text (see the note at the top of the file). + expect(callTool).not.toHaveBeenCalled(); + api.unmount(); + }); + + it("names every missing required argument (#2123)", async () => { + const callTool = vi.fn(); + const tool = makeTool({ + inputSchema: { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + required: ["a", "b"], + }, + } as unknown as Partial); + const api = render( + , + ); + await tick(); + setSubmitValue({}); + api.stdin.write("\r"); + await tick(); + await tick(); + // Two missing names, which is also the plural branch of the message. + expect(callTool).not.toHaveBeenCalled(); + api.unmount(); + }); + it("renders the form initially without invoking the client", async () => { const callTool = vi.fn(); const api = render( diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index ddd61e8134..feab34863a 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { decodeFormValues, schemaToForm } from "../src/utils/schemaToForm.js"; +import { + decodeFormValues, + missingRequiredFields, + schemaToForm, +} from "../src/utils/schemaToForm.js"; describe("schemaToForm", () => { it("returns an empty Parameters section when there is no schema", () => { @@ -655,6 +659,26 @@ describe("schemaToForm", () => { ).toEqual({ n: 7, a: "x" }); }); + it("keeps a decoded argument named __proto__", () => { + // Assigning it would invoke the legacy prototype setter, so a field + // prefixed safely in the form would vanish on the way to the call. + const schema = { + type: "object", + anyOf: [ + { + type: "object", + properties: { ["__proto__"]: { type: "string" } }, + }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }; + const decoded = decodeFormValues(schema, { + __variant: "0", + __b0____proto__: "kept", + }); + expect(Object.hasOwn(decoded, "__proto__")).toBe(true); + }); + it("keeps a base argument whose name looks generated", () => { const schema = { type: "object", @@ -695,4 +719,103 @@ describe("schemaToForm", () => { expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]); }); }); + + describe("edge shapes (#2123)", () => { + it("renders a union that declares no root properties", () => { + const form = schemaToForm( + { + type: "object", + anyOf: [ + { type: "object", properties: { a: { type: "string" } } }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }, + "no_base", + ); + expect(form.sections[0]!.fields.map((f) => f.name)).toEqual([ + "__variant", + ]); + expect(form.sections[1]!.fields.map((f) => f.name)).toEqual(["__b0__a"]); + }); + + it("returns the values unchanged for a schema with no properties", () => { + const values = { anything: "x" }; + expect(decodeFormValues({ type: "object" }, values)).toBe(values); + }); + + it("ignores a malformed property declaration when restoring constants", () => { + // `properties` values are `unknown`; a `null` entry must not throw on the + // way out of the form any more than it does on the way in. + const schema = { + type: "object", + anyOf: [ + { + type: "object", + properties: { broken: null, kind: { const: "a" } }, + }, + { type: "object", properties: { kind: { const: "b" } } }, + ] as unknown[], + }; + expect( + decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }), + ).toEqual({ kind: "a" }); + }); + }); + + describe("missingRequiredFields (#2123)", () => { + const REQUIRED_UNION = { + type: "object", + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + required: ["kind", "phone"], + }, + ], + }; + + it("reports what the chosen branch requires and the values omit", () => { + // A branch's fields render optional — a static form cannot demand every + // branch's — so the requirement is checked against the chosen shape here + // rather than sending a call known to violate the schema. + expect( + missingRequiredFields( + REQUIRED_UNION, + { kind: "email" }, + { __variant: "0" }, + ), + ).toEqual(["address"]); + }); + + it("reports nothing once the chosen branch is satisfied", () => { + expect( + missingRequiredFields( + REQUIRED_UNION, + { kind: "sms", phone: "555" }, + { __variant: "1" }, + ), + ).toEqual([]); + }); + + it("checks the root's own required fields when there is no union", () => { + const schema = { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }; + expect(missingRequiredFields(schema, {})).toEqual(["message"]); + expect(missingRequiredFields(schema, { message: "hi" })).toEqual([]); + }); + }); }); diff --git a/clients/tui/src/components/ToolTestModal.tsx b/clients/tui/src/components/ToolTestModal.tsx index 3b2d28caf4..1b0b726af5 100644 --- a/clients/tui/src/components/ToolTestModal.tsx +++ b/clients/tui/src/components/ToolTestModal.tsx @@ -5,7 +5,11 @@ import { InspectorClient } from "@inspector/core/mcp/index.js"; import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import type { Tool, CallToolResult } from "@modelcontextprotocol/client"; import type { JsonValue } from "@inspector/core/mcp/index.js"; -import { decodeFormValues, schemaToForm } from "../utils/schemaToForm.js"; +import { + decodeFormValues, + missingRequiredFields, + schemaToForm, +} from "../utils/schemaToForm.js"; import { ScrollView, type ScrollViewRef } from "ink-scroll-view"; interface ToolTestModalProps { @@ -123,6 +127,23 @@ export function ToolTestModal({ // the base fields plus the chosen branch's, and nothing from the others. const values = decodeFormValues(tool.inputSchema, rawValues); + // A branch's fields are rendered optional — only one alternative applies to + // a call, and requiring every branch's would deadlock a static form — so + // the chosen shape's own requirements are checked here instead. Reported + // rather than sent: a call known to violate the schema teaches the user + // nothing about the server (#2123). + const missing = missingRequiredFields(tool.inputSchema, values, rawValues); + if (missing.length > 0) { + setResult({ + input: values, + output: null, + error: `Missing required argument${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`, + duration: 0, + }); + setState("results"); + return; + } + setState("loading"); const startTime = Date.now(); diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 4bc2fadb40..0a519d9449 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -138,7 +138,7 @@ function branchFields( branches: { declaredFields: string[] }[], index: number, ): string[] { - const own = branches[index]?.declaredFields ?? []; + const own = branches[index]!.declaredFields; return [...new Set([...own, ...sharedFieldNames(base, branches)])]; } @@ -256,11 +256,7 @@ export function decodeFormValues( } const { variant, prefix } = generatedNames(base, branches); - const selected = Number(values[variant]); - const branchIndex = - Number.isInteger(selected) && selected >= 0 && selected < branches.length - ? selected - : 0; + const branchIndex = selectedBranchIndex(base, branches, values); const branch = branches[branchIndex]!; const generated = new Set([variant]); @@ -270,21 +266,42 @@ export function decodeFormValues( } }); - const decoded: Record = {}; - for (const [name, value] of Object.entries(values)) { - if (!generated.has(name)) { - decoded[name] = value; - } - } - for (const name of branchFields(base, branches, branchIndex)) { - const value = values[branchFieldName(prefix, branchIndex, name)]; - if (value !== undefined) { - decoded[name] = value; - } - } + // Built through `fromEntries` rather than by assignment: `__proto__` is a + // legal argument name, and assigning it would invoke the legacy prototype + // setter — the field would be prefixed safely in the form and then vanish on + // the way to the call. + const decoded: Record = Object.fromEntries([ + ...Object.entries(values).filter(([name]) => !generated.has(name)), + ...branchFields(base, branches, branchIndex) + .map( + (name) => + [name, values[branchFieldName(prefix, branchIndex, name)]] as const, + ) + .filter(([, value]) => value !== undefined), + ]); + /* v8 ignore next -- an offerable branch always carries properties */ return applyConstants(branch.schema.properties ?? {}, decoded); } +/** + * Which branch the variant select names, clamped to one that exists — a form + * value is whatever the user's terminal produced, and the fallback is the first + * branch, which is what the select opens on. + */ +function selectedBranchIndex( + base: { properties?: Record }, + branches: { declaredFields: string[] }[], + values: Record, +): number { + const { variant } = generatedNames(base, branches); + const selected = Number(values[variant]); + return Number.isInteger(selected) && + selected >= 0 && + selected < branches.length + ? selected + : 0; +} + /** Overwrite every `const`-pinned field with the value its schema fixes. */ function applyConstants( properties: Record, @@ -294,11 +311,10 @@ function applyConstants( ([, schema]) => constOf(schema) !== undefined, ); if (pinned.length === 0) return values; - const result = { ...values }; - for (const [name, schema] of pinned) { - result[name] = constOf(schema) as T; - } - return result; + return Object.fromEntries([ + ...Object.entries(values), + ...pinned.map(([name, schema]) => [name, constOf(schema) as T]), + ]); } /** Build the ink-form fields for one already-flattened object schema. */ @@ -365,6 +381,7 @@ function buildFields(schema: JsonSchemaObject): FormField[] { field = { type: "select", ...baseField, + /* v8 ignore next -- guarded by `isStringEnum(property.enum)` above */ options: toSelectOptions(property.enum ?? [], property.enumNames), } as FormField; } else { @@ -420,3 +437,32 @@ function buildFields(schema: JsonSchemaObject): FormField[] { return fields; } + +/** + * The required fields the chosen shape does not supply — what a static form + * cannot enforce for itself. + * + * A branch's fields are rendered optional because only one alternative applies + * to a call and ink-form would otherwise demand every branch's, deadlocking the + * form. That makes the *form* satisfiable, not the call: selecting `email` and + * leaving `address` empty still violates the schema. So the requirement is + * checked at submit instead, against the branch the variant select names, and + * the caller reports it rather than sending a call known to be invalid. + * + * Takes the **decoded** values — what would actually be sent. + */ +export function missingRequiredFields( + schema: JsonSchemaObject | null | undefined, + decoded: Record, + rawValues: Record = {}, +): string[] { + const { base, branches } = resolveRootUnion(schema ?? {}); + const effective = + branches.length === 0 + ? base + : branches[selectedBranchIndex(base, branches, rawValues)]!.schema; + return (effective.required ?? []).filter((name) => { + const value = decoded[name]; + return value === undefined || value === ""; + }); +} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 5b01de8d31..46053d1301 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -703,18 +703,27 @@ export function SchemaForm({ // incoming branch pins to a `const` is never carried: the branches of a // discriminated union share the discriminator's *name* and disagree about // its value. - const outgoing = new Set(activeBranch?.declaredFields ?? []); const incoming = new Set(nextBranch.declaredFields); - const carried: Record = {}; - for (const [name, fieldSchema] of Object.entries(nextProperties)) { - if ( - values[name] !== undefined && - fieldSchema.const === undefined && - !(outgoing.has(name) && incoming.has(name)) - ) { - carried[name] = values[name]; - } - } + // `hasOwn` and `fromEntries`, never `values[name]` on its own or an + // assignment: `constructor` is a legal argument name whose inherited value + // would otherwise be read as one the user supplied, and `__proto__` is one + // an assignment would drop into the legacy prototype setter. + const carried = Object.fromEntries( + Object.entries(nextProperties) + .filter( + ([name, fieldSchema]) => + Object.hasOwn(values, name) && + values[name] !== undefined && + fieldSchema.const === undefined && + // Carried only where the incoming branch leaves the root's + // declaration as it found it. Anything the incoming branch declares + // itself is reset: it may type the name differently from wherever + // the value was typed, so a `3` from a number field would otherwise + // land in its checkbox. + !incoming.has(name), + ) + .map(([name]) => [name, values[name]]), + ); onChange({ ...collectSchemaDefaults(nextBranch.schema), ...carried }); } diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 4b44aa0229..cbe0ea3432 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -739,6 +739,12 @@ describe("admitsNull", () => { expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true); }); + it("honors a sibling `nullable` flag", () => { + expect(admitsNull({ type: "string", nullable: true, const: null })).toBe( + true, + ); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index bc363652cd..1df349e24b 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -327,6 +327,16 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("treats an empty additionalProperties schema as permissive", () => { + // `{}` is the JSON Schema equivalent of `true` — it constrains nothing. + const { branches } = resolveRootUnion({ + type: "object", + additionalProperties: {}, + anyOf: [EMAIL, SMS], + }); + expect(branches).toHaveLength(2); + }); + it("allows a restrictive additionalProperties the branches stay within", () => { const { branches } = resolveRootUnion({ type: "object", @@ -435,6 +445,66 @@ describe("resolveRootUnion", () => { }); }); + describe("oneOf exclusivity", () => { + it("offers a discriminated oneOf", () => { + expect( + resolveRootUnion({ type: "object", oneOf: [EMAIL, SMS] }).branches, + ).toHaveLength(2); + }); + + it("declines a oneOf whose alternatives are not mutually exclusive", () => { + // `oneOf` demands that EXACTLY one alternative match; flattening offers + // them as if any would do. Here entering `a` satisfies both, so a call + // the form calls valid is one the server refuses. + const { branches } = resolveRootUnion({ + type: "object", + oneOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + }, + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + required: ["a"], + }, + ], + }); + expect(branches).toEqual([]); + }); + + it("still offers the same alternatives under anyOf", () => { + // `anyOf` makes no exclusivity claim, so overlapping alternatives are + // exactly what it means. + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + }, + { + type: "object", + properties: { a: { type: "string" }, b: { type: "string" } }, + required: ["a"], + }, + ], + }); + expect(branches).toHaveLength(2); + }); + + it("declines a oneOf whose named discriminator does not distinguish", () => { + const { branches } = resolveRootUnion({ + type: "object", + discriminator: { propertyName: "kind" }, + oneOf: [EMAIL, { ...EMAIL, properties: { ...EMAIL.properties } }], + }); + expect(branches).toEqual([]); + }); + }); + describe("unions it declines to offer", () => { // Each of these would produce a picker with an option that renders // nothing, which is the failure this module exists to prevent. @@ -572,6 +642,29 @@ describe("resolveRootUnion", () => { ); }); + it("falls back to the branch whose required fields the values supply", () => { + // An undiscriminated union still has shapes, and the required-field gate + // accepts any satisfied branch — so the picker must open on the one the + // values satisfy rather than showing a shape they do not describe. + const undiscriminated = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { address: { type: "string" } }, + required: ["address"], + }, + { + type: "object", + properties: { phone: { type: "string" } }, + required: ["phone"], + }, + ], + }).branches; + expect(selectBranchIndex(undiscriminated, { phone: "555" })).toBe(1); + expect(selectBranchIndex(undiscriminated, {})).toBeNull(); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 580c0e8fac..1709bd67ca 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -397,7 +397,10 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // would let the gate accept a `null` the schema forbids. The opaque // applicators (`not`, `allOf`, `oneOf`) are already refused above. if (schema.const === null) { - return schema.anyOf === undefined && typeAdmitsNull(schema.type); + return ( + schema.anyOf === undefined && + (schema.nullable === true || typeAdmitsNull(schema.type)) + ); } if (schema.nullable === true) { diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index d44fcce380..09374b9dfe 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -271,6 +271,34 @@ function conflictsWithBase( ); } +/** + * Whether some property discriminates the alternatives: declared by **every** + * member, pinned by each to a `const`, and pinned to a *different* one by each. + * That is what makes at most one alternative matchable, which is the constraint + * `oneOf` states and flattening cannot otherwise keep. + * + * When the schema names a `discriminator`, only that property is considered — + * the author has said which one carries the distinction. + */ +function hasDiscriminator( + members: RootUnionSchema[], + named: string | undefined, +): boolean { + const first = propertiesOf(members[0] ?? {}) ?? {}; + const candidates = named !== undefined ? [named] : Object.keys(first); + return candidates.some((name) => { + const constants = members.map((member) => { + const property = toBranch((propertiesOf(member) ?? {})[name]) as { + const?: unknown; + } | null; + return property?.const; + }); + if (constants.some((value) => value === undefined)) return false; + const seen = new Set(constants.map((value) => JSON.stringify(value))); + return seen.size === constants.length; + }); +} + /** * Every property name the schema's composition members declare, whether or not * the composition could be flattened. @@ -441,6 +469,7 @@ export function resolveRootUnion( if (schema.oneOf !== undefined && schema.anyOf !== undefined) { return { base, branches: [] }; } + const isExclusiveUnion = schema.oneOf !== undefined; const members = schema.oneOf ?? schema.anyOf ?? []; const branches = members.map(toBranch); if ( @@ -468,7 +497,12 @@ export function resolveRootUnion( const additional = base.additionalProperties; const restrictsAdditional = additional === false || - (typeof additional === "object" && additional !== null); + // An EMPTY schema object is the JSON Schema equivalent of `true` — it + // constrains nothing, so it is no reason to decline. Only a schema that + // states something is. + (typeof additional === "object" && + additional !== null && + Object.keys(additional).length > 0); const baseNames = propertiesOf(base) ?? {}; if ( restrictsAdditional && @@ -481,6 +515,22 @@ export function resolveRootUnion( return { base, branches: [] }; } + // `oneOf` demands that **exactly one** alternative match, which flattening + // cannot preserve: the merged branches are offered as if any of them would + // do. That is only safe when the alternatives are mutually exclusive by + // construction — a discriminator, i.e. some property every branch pins to a + // `const` of its own. Without one, two branches can accept the same + // arguments, and a call the form calls valid is one the server refuses. + if ( + isExclusiveUnion && + !hasDiscriminator( + branches as RootUnionSchema[], + schema.discriminator?.propertyName, + ) + ) { + return { base, branches: [] }; + } + const discriminatorProperty = schema.discriminator?.propertyName; return { base, @@ -533,5 +583,25 @@ export function selectBranchIndex( matches.push(index); } }); - return matches.length === 1 ? matches[0] : null; + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return null; + + // No discriminator settled it. A union need not have one, and values still + // belong to a shape — so fall back to the branch whose own required fields + // the values supply, when exactly one branch's do. Without this, values for + // an undiscriminated branch open the picker on the first branch while the + // required-field gate (which accepts *any* satisfied branch) lets them be + // submitted, so the form shows one shape and sends another. + if (Object.keys(values).length === 0) return null; + const satisfied = branches.filter((branch) => { + const required = branch.schema.required ?? []; + const own = required.filter((name) => branch.declaredFields.includes(name)); + return ( + own.length > 0 && + own.every( + (name) => Object.hasOwn(values, name) && values[name] !== undefined, + ) + ); + }); + return satisfied.length === 1 ? branches.indexOf(satisfied[0]) : null; } From 246ee1c262548dfd67e8cbd406ba29070a71eb29 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:07:50 -0400 Subject: [PATCH 021/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=209=20=E2=80=94=20canonical=20constant=20comparison,=20name-?= =?UTF-8?q?based=20inference,=20permissive=20schemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: schema values are compared through a key-sorted rendering, so two discriminator constants written as {a,b} and {b,a} are recognized as the same value rather than as mutually exclusive alternatives. - selectBranchIndex: after the const and required passes, a name only ONE alternative declares identifies that alternative; a name several declare is ambiguous and says nothing. - additionalProperties counts as restrictive only when its schema states an assertion — an annotation-only `{ title: ... }` is the equivalent of `true`, like `{}`. - Drop the unjustified double casts from the ToolTestModal fixtures; the schemas are assignable to Partial as written. Signed-off-by: cliffhall --- clients/tui/__tests__/ToolTestModal.test.tsx | 4 +- clients/web/src/test/core/rootUnion.test.ts | 56 +++++++++++++++- core/json/rootUnion.ts | 69 ++++++++++++++++---- 3 files changed, 112 insertions(+), 17 deletions(-) diff --git a/clients/tui/__tests__/ToolTestModal.test.tsx b/clients/tui/__tests__/ToolTestModal.test.tsx index a933bc17d0..63c2100c8b 100644 --- a/clients/tui/__tests__/ToolTestModal.test.tsx +++ b/clients/tui/__tests__/ToolTestModal.test.tsx @@ -100,7 +100,7 @@ describe("ToolTestModal", () => { }, ], }, - } as unknown as Partial); + }); const api = render( { properties: { a: { type: "string" }, b: { type: "string" } }, required: ["a", "b"], }, - } as unknown as Partial); + }); const api = render( { expect(branches).toEqual([]); }); + it("treats an annotation-only additionalProperties schema as permissive", () => { + // `{ title: … }` asserts nothing, so it is the equivalent of `true` just + // as `{}` is — declining on key count alone would recreate the empty form. + const { branches } = resolveRootUnion({ + type: "object", + additionalProperties: { title: "Extra value" }, + anyOf: [EMAIL, SMS], + }); + expect(branches).toHaveLength(2); + }); + it("treats an empty additionalProperties schema as permissive", () => { // `{}` is the JSON Schema equivalent of `true` — it constrains nothing. const { branches } = resolveRootUnion({ @@ -495,6 +506,25 @@ describe("resolveRootUnion", () => { expect(branches).toHaveLength(2); }); + it("compares object constants irrespective of member order", () => { + // Member order carries no meaning in JSON, so these two alternatives are + // pinned to the SAME value and both match — not mutually exclusive. + const { branches } = resolveRootUnion({ + type: "object", + oneOf: [ + { + type: "object", + properties: { tag: { const: { a: 1, b: 2 } }, x: {} }, + }, + { + type: "object", + properties: { tag: { const: { b: 2, a: 1 } }, y: {} }, + }, + ], + }); + expect(branches).toEqual([]); + }); + it("declines a oneOf whose named discriminator does not distinguish", () => { const { branches } = resolveRootUnion({ type: "object", @@ -680,7 +710,9 @@ describe("resolveRootUnion", () => { expect(selectBranchIndex(ambiguous, { kind: "email" })).toBeNull(); }); - it("reports none for a branch that pins nothing", () => { + it("identifies a branch from a name only it declares", () => { + // Nothing is pinned and nothing is required, but `a` belongs to one + // alternative as plainly as a discriminator would. const unpinned = resolveRootUnion({ type: "object", anyOf: [ @@ -688,7 +720,27 @@ describe("resolveRootUnion", () => { { type: "object", properties: { b: { type: "string" } } }, ], }).branches; - expect(selectBranchIndex(unpinned, { a: "x" })).toBeNull(); + expect(selectBranchIndex(unpinned, { a: "x" })).toBe(0); + expect(selectBranchIndex(unpinned, { b: "x" })).toBe(1); + }); + + it("treats a name several branches declare as saying nothing", () => { + const shared = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { both: { type: "string" }, a: { type: "string" } }, + }, + { + type: "object", + properties: { both: { type: "string" }, b: { type: "string" } }, + }, + ], + }).branches; + expect(selectBranchIndex(shared, { both: "x" })).toBeNull(); + // …and both branches named at once is no answer either. + expect(selectBranchIndex(shared, { a: "x", b: "y" })).toBeNull(); }); }); }); diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 09374b9dfe..f18fa74879 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -171,9 +171,33 @@ const ANNOTATION_KEYWORDS = new Set([ "default", ]); +/** + * A JSON rendering whose object keys are sorted, so two values that differ only + * in the order their properties were written render identically. + * + * Member order carries no meaning in JSON, so `{ a: 1, b: 2 }` and + * `{ b: 2, a: 1 }` are the same value — a plain `JSON.stringify` comparison + * would call them different and, for a discriminator, would report two + * alternatives as mutually exclusive when both accept the same input. + */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value as Record) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map( + ([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`, + ); + return `{${entries.join(",")}}`; + } + return JSON.stringify(value) ?? "undefined"; +} + /** Structural equality, via canonical JSON — enough for schema keyword values. */ function sameValue(a: unknown, b: unknown): boolean { - return a === b || JSON.stringify(a) === JSON.stringify(b); + return a === b || canonicalJson(a) === canonicalJson(b); } /** @@ -294,7 +318,7 @@ function hasDiscriminator( return property?.const; }); if (constants.some((value) => value === undefined)) return false; - const seen = new Set(constants.map((value) => JSON.stringify(value))); + const seen = new Set(constants.map(canonicalJson)); return seen.size === constants.length; }); } @@ -497,12 +521,15 @@ export function resolveRootUnion( const additional = base.additionalProperties; const restrictsAdditional = additional === false || - // An EMPTY schema object is the JSON Schema equivalent of `true` — it - // constrains nothing, so it is no reason to decline. Only a schema that - // states something is. + // A schema that constrains nothing is the equivalent of `true`, and that is + // not only the empty object: `{ title: "Extra value" }` is annotation and + // no more. Declining on key count alone would recreate the empty form for a + // legal permissive schema, so what counts is whether an assertion is there. (typeof additional === "object" && additional !== null && - Object.keys(additional).length > 0); + Object.keys(additional).some( + (keyword) => !ANNOTATION_KEYWORDS.has(keyword), + )); const baseNames = propertiesOf(base) ?? {}; if ( restrictsAdditional && @@ -593,15 +620,31 @@ export function selectBranchIndex( // required-field gate (which accepts *any* satisfied branch) lets them be // submitted, so the form shows one shape and sends another. if (Object.keys(values).length === 0) return null; + const supplied = (name: string) => + Object.hasOwn(values, name) && values[name] !== undefined; + const satisfied = branches.filter((branch) => { const required = branch.schema.required ?? []; const own = required.filter((name) => branch.declaredFields.includes(name)); - return ( - own.length > 0 && - own.every( - (name) => Object.hasOwn(values, name) && values[name] !== undefined, - ) - ); + return own.length > 0 && own.every(supplied); }); - return satisfied.length === 1 ? branches.indexOf(satisfied[0]) : null; + if (satisfied.length === 1) return branches.indexOf(satisfied[0]); + + // Nothing is required, or several branches are satisfied. A name only ONE + // alternative declares is still evidence: supplying `phone` where only the + // SMS branch declares it names that shape as clearly as a discriminator + // would. A name more than one declares is ambiguous and says nothing. + const exclusiveTo = new Map(); + branches.forEach((branch, index) => { + for (const name of branch.declaredFields) { + exclusiveTo.set(name, exclusiveTo.has(name) ? -1 : index); + } + }); + const named = new Set( + Object.keys(values) + .filter(supplied) + .map((name) => exclusiveTo.get(name)) + .filter((index): index is number => index !== undefined && index >= 0), + ); + return named.size === 1 ? [...named][0] : null; } From 775fec0f6a933886832ba420b8ec7a868a9bccc4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:23:14 -0400 Subject: [PATCH 022/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2010=20=E2=80=94=20structural=20const=20match,=20deep-link?= =?UTF-8?q?=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectBranchIndex compares a discriminator structurally: a `const` may be an object or array, and deep-link arguments arrive as freshly parsed instances that could never be reference-equal to the schema's own. - New applySchemaConstants, applied by InspectorView AFTER the deep link's `appArgs` are spread over the seeded defaults. The form renders a pinned field read-only, so a link disagreeing with one would otherwise auto-open an App with a hidden value contradicting the shape on screen. Signed-off-by: cliffhall --- .../views/InspectorView/InspectorView.tsx | 20 ++++++++----- clients/web/src/test/core/rootUnion.test.ts | 19 ++++++++++++ clients/web/src/utils/jsonUtils.test.ts | 23 ++++++++++++++ clients/web/src/utils/jsonUtils.ts | 30 +++++++++++++++++++ core/json/rootUnion.ts | 7 ++++- 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 74e2f8033c..6e845dba4c 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -103,7 +103,11 @@ import { correlatedFetchStatusById, revealableMessageIds, } from "../../../utils/correlateTransportErrors"; -import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; +import { + applySchemaConstants, + collectSchemaDefaults, + toFormSchema, +} from "../../../utils/jsonUtils"; import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation"; const SORT_DEFAULT: SortDirection = "newest-first"; @@ -1040,13 +1044,15 @@ export function InspectorView({ // than the first, and defaults seeded from the wrong branch would sit in // the submitted arguments where the form — showing the branch the args // identify — never displays them (#2123). - const formValues = { - ...collectSchemaDefaults( - toFormSchema(target.inputSchema) ?? {}, - deepLink.appArgs ?? {}, - ), + // …and the schema's constants are re-applied *after* the overlay: a field + // the form renders read-only cannot be corrected by the user, so a deep + // link disagreeing with one would otherwise auto-open with a hidden value + // contradicting the shape on screen. + const appFormSchema = toFormSchema(target.inputSchema) ?? {}; + const formValues = applySchemaConstants(appFormSchema, { + ...collectSchemaDefaults(appFormSchema, deepLink.appArgs ?? {}), ...deepLink.appArgs, - }; + }); // Seed the selection directly rather than routing through // AppsScreen.handleSelect. This deliberately bypasses handleSelect's // no-input-app auto-launch: a deep link must never invoke a tool against diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index fd563ac681..f98d890349 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -695,6 +695,25 @@ describe("resolveRootUnion", () => { expect(selectBranchIndex(undiscriminated, {})).toBeNull(); }); + it("matches an object-valued discriminator structurally", () => { + // Deep-link arguments are freshly parsed instances, never `===` the + // schema's own constant. + const objectPinned = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { tag: { const: { a: 1 } }, x: { type: "string" } }, + }, + { + type: "object", + properties: { tag: { const: { a: 2 } }, y: { type: "string" } }, + }, + ], + }).branches; + expect(selectBranchIndex(objectPinned, { tag: { a: 2 } })).toBe(1); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 7f3c1a0c12..54c2fa25a3 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -6,6 +6,7 @@ import { getValueAtPath, collectSchemaDefaults, hasMissingRequiredFields, + applySchemaConstants, } from "./jsonUtils"; import type { InspectorFormSchema } from "./jsonUtils"; @@ -432,6 +433,28 @@ describe("root composition (#2123)", () => { expect(hasMissingRequiredFields(schema, values)).toBe(false); }); + it("re-applies a branch's constants over conflicting supplied values", () => { + // A read-only field cannot be corrected by the user, so a deep link + // disagreeing with a `const` must not survive into the submitted arguments. + expect(applySchemaConstants(UNION, { kind: "sms", note: "hi" })).toEqual({ + kind: "sms", + note: "hi", + }); + expect(applySchemaConstants(UNION, { kind: "nonsense" })).toEqual({ + kind: "email", + }); + }); + + it("leaves values alone when nothing is pinned", () => { + const values = { a: 1 }; + expect( + applySchemaConstants( + { type: "object", properties: { a: { type: "number" } } }, + values, + ), + ).toBe(values); + }); + it("blocks submission while no branch is satisfied", () => { expect(hasMissingRequiredFields(UNION, {})).toBe(true); expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index c078c03605..4145e4a98d 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -174,6 +174,36 @@ export function collectSchemaDefaults( return result; } +/** + * Overwrite every `const`-pinned field with the value its schema fixes. + * + * The form renders such a field read-only, so a value disagreeing with it can + * only have come from outside the form — an App deep link's `appArgs`, which + * are spread over the seeded defaults and would otherwise leave the arguments + * claiming a shape the user is being shown the opposite of (#2123). Apply this + * *after* any such overlay. + * + * The branch is chosen the same way {@link collectSchemaDefaults} chooses it, + * from the values themselves, so the constants applied belong to the shape the + * form will display. + */ +export function applySchemaConstants( + schema: InspectorFormSchema, + values: Record, +): Record { + const { base, branches } = resolveRootUnion(schema); + const selected = selectBranchIndex(branches, values) ?? 0; + const properties = (branches[selected]?.schema ?? base).properties ?? {}; + const pinned = Object.entries(properties).filter( + ([, fieldSchema]) => fieldSchema.const !== undefined, + ); + if (pinned.length === 0) return values; + return Object.fromEntries([ + ...Object.entries(values), + ...pinned.map(([name, fieldSchema]) => [name, fieldSchema.const]), + ]); +} + /** * 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 diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index f18fa74879..04f6a561d5 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -605,7 +605,12 @@ export function selectBranchIndex( const supplied = pinned.filter(([name]) => values[name] !== undefined); if ( supplied.length > 0 && - supplied.every(([name, constValue]) => values[name] === constValue) + // Structural, not reference: a `const` may be an object or an array, and + // deep-link arguments arrive as freshly parsed instances that could never + // be `===` the schema's own. + supplied.every(([name, constValue]) => + sameValue(values[name], constValue), + ) ) { matches.push(index); } From a69cb58451499ec99c75fd23688a4634005d7760 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:36:13 -0400 Subject: [PATCH 023/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2011=20=E2=80=94=20recurse=20constants=20into=20nested=20obj?= =?UTF-8?q?ects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applySchemaConstants only corrected top-level pinned fields, but the deep link's overlay replaces a nested object wholesale rather than merging into it — so `{ config: { kind: "sms" } }` slipped past a nested read-only `kind: { const: "email" }` the form was displaying. It now recurses into object-valued properties, resolving each one's own root union the same way. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 21 +++++++++++++++ clients/web/src/utils/jsonUtils.ts | 36 +++++++++++++++++++------ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 54c2fa25a3..ff8bf1b3c9 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -445,6 +445,27 @@ describe("root composition (#2123)", () => { }); }); + it("re-applies a nested object's constants", () => { + // The overlay replaces the whole nested object rather than merging into it, + // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past + // the pinned `kind` the nested form displays. + const schema: InspectorFormSchema = { + type: "object", + properties: { + config: { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + to: { type: "string" }, + }, + }, + }, + }; + expect( + applySchemaConstants(schema, { config: { kind: "sms", to: "a@b.c" } }), + ).toEqual({ config: { kind: "email", to: "a@b.c" } }); + }); + it("leaves values alone when nothing is pinned", () => { const values = { a: 1 }; expect( diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 4145e4a98d..a60387a99c 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -194,14 +194,34 @@ export function applySchemaConstants( const { base, branches } = resolveRootUnion(schema); const selected = selectBranchIndex(branches, values) ?? 0; const properties = (branches[selected]?.schema ?? base).properties ?? {}; - const pinned = Object.entries(properties).filter( - ([, fieldSchema]) => fieldSchema.const !== undefined, - ); - if (pinned.length === 0) return values; - return Object.fromEntries([ - ...Object.entries(values), - ...pinned.map(([name, fieldSchema]) => [name, fieldSchema.const]), - ]); + + const corrections: [string, unknown][] = []; + for (const [name, rawSchema] of Object.entries(properties)) { + const fieldSchema = normalizeNullableUnion(rawSchema); + if (fieldSchema.const !== undefined) { + corrections.push([name, fieldSchema.const]); + continue; + } + // Recurse: a nested object renders its own read-only fields, and the + // overlay replaces the whole object rather than merging into it — so a + // link naming `{ config: { kind: "sms" } }` would otherwise slip a value + // past the pinned `kind` the nested form is displaying. + const nested = values[name]; + if ( + typeof nested === "object" && + nested !== null && + !Array.isArray(nested) + ) { + const corrected = applySchemaConstants( + fieldSchema, + nested as Record, + ); + if (corrected !== nested) corrections.push([name, corrected]); + } + } + + if (corrections.length === 0) return values; + return Object.fromEntries([...Object.entries(values), ...corrections]); } /** From 41be9e7aa885ee9078511cde399d549e02b6cf80 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:48:54 -0400 Subject: [PATCH 024/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2012=20=E2=80=94=20prototype-safe=20default=20seeding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectSchemaDefaults assigned by name, so a legal field called `__proto__` went to the legacy prototype setter instead of the result. A required one pinned by `const` would then render read-only and be seeded with nothing, leaving submit permanently disabled. Seeded through defineProperty now, the way the other schema-record paths already build their maps. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 12 ++++++++++++ clients/web/src/utils/jsonUtils.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index ff8bf1b3c9..1d2fd8aec5 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -445,6 +445,18 @@ describe("root composition (#2123)", () => { }); }); + it("seeds a field named __proto__", () => { + // A plain assignment would invoke the legacy prototype setter, leaving a + // required pinned field displayed read-only and seeded with nothing. + const seeded = collectSchemaDefaults({ + type: "object", + properties: Object.fromEntries([ + ["__proto__", { type: "string", const: "kept" }], + ]), + }); + expect(Object.hasOwn(seeded, "__proto__")).toBe(true); + }); + it("re-applies a nested object's constants", () => { // The overlay replaces the whole nested object rather than merging into it, // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index a60387a99c..823e4f339f 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -145,6 +145,17 @@ export function collectSchemaDefaults( const selected = selectBranchIndex(branches, knownValues) ?? 0; const properties = (branches[selected]?.schema ?? base).properties ?? {}; const result: Record = {}; + // `properties` is a JSON record, so `__proto__` is a legal field name that a + // plain assignment would drop into the legacy prototype setter rather than + // keep — a required one pinned by `const` would then display read-only and + // be seeded with nothing, leaving submit permanently disabled (#2123). + const seed = (name: string, value: unknown) => + Object.defineProperty(result, name, { + value, + writable: true, + enumerable: true, + configurable: true, + }); 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 @@ -161,13 +172,13 @@ export function collectSchemaDefaults( // `const` rejects, and seeding that would submit an invalid argument // through a field rendered read-only, leaving the user no way to correct // it. - result[fieldName] = fieldSchema.const; + seed(fieldName, fieldSchema.const); } else if (fieldSchema.default !== undefined) { - result[fieldName] = fieldSchema.default; + seed(fieldName, fieldSchema.default); } else if (fieldSchema.type === "object" && fieldSchema.properties) { const nested = collectSchemaDefaults(fieldSchema); if (Object.keys(nested).length > 0) { - result[fieldName] = nested; + seed(fieldName, nested); } } } From df1e39e62381ba8ef10b6d2586a03970f4e49018 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 05:21:21 -0400 Subject: [PATCH 025/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2013=20=E2=80=94=20cross-keyword=20contradictions,=20own-pro?= =?UTF-8?q?perty=20required=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: keywords the two declarations state SEPARATELY can contradict too. A merged `const` is now checked against its own `type` and `enum`, so root `x: { type: "string" }` under branch `x: { const: 1 }` declines instead of seeding an immutable 1 into a string field. An integer const still satisfies a `number` type — the one direction JSON Schema widens. - TUI missingRequiredFields tests `Object.hasOwn` first: an argument legally named `constructor` resolved to the inherited one and read as supplied, and the call went out without it. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 11 ++++ clients/tui/src/utils/schemaToForm.ts | 4 ++ clients/web/src/test/core/rootUnion.test.ts | 40 +++++++++++++++ core/json/rootUnion.ts | 56 ++++++++++++++++++--- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index feab34863a..cb9f1df2a0 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -808,6 +808,17 @@ describe("schemaToForm", () => { ).toEqual([]); }); + it("does not mistake an inherited property for a supplied argument", () => { + // `constructor` is a legal argument name; reading it off the prototype + // would report it as present and send the call without it. + const schema = { + type: "object", + properties: { constructor: { type: "string" } }, + required: ["constructor"], + }; + expect(missingRequiredFields(schema, {})).toEqual(["constructor"]); + }); + it("checks the root's own required fields when there is no union", () => { const schema = { type: "object", diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 0a519d9449..196b735743 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -462,6 +462,10 @@ export function missingRequiredFields( ? base : branches[selectedBranchIndex(base, branches, rawValues)]!.schema; return (effective.required ?? []).filter((name) => { + // `hasOwn` first: an argument legally named `constructor` would otherwise + // resolve to the inherited one and read as supplied, and the call would go + // out without it. + if (!Object.hasOwn(decoded, name)) return true; const value = decoded[name]; return value === undefined || value === ""; }); diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index f98d890349..384703ee3e 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -358,6 +358,46 @@ describe("resolveRootUnion", () => { expect(branches).toHaveLength(2); }); + it("declines a branch whose const its root type rejects", () => { + // The two share no keyword, yet nothing satisfies both — and the merged + // declaration would seed an immutable `1` into a string field. + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { type: "string" } }, + anyOf: [ + { type: "object", properties: { x: { const: 1 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + }); + + it("declines a branch whose const its root enum excludes", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { enum: ["a", "b"] } }, + anyOf: [ + { type: "object", properties: { x: { const: "c" } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + }); + + it("accepts a const its root type and enum admit", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { type: "number", enum: [1, 2] } }, + anyOf: [ + // An integer const satisfies a `number` type — the one direction JSON + // Schema widens. + { type: "object", properties: { x: { const: 1 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toHaveLength(2); + }); + it("declines a union whose branch contradicts the root's type for a field", () => { // `string` under a base `number` describes a value that cannot exist, so // flattening it would render one type and accept what the schema rejects. diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 04f6a561d5..fbb8d92eec 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -224,12 +224,56 @@ function conflicts(baseProperty: unknown, branchProperty: unknown): boolean { } const left = a as Record; const right = b as Record; - return Object.keys(right).some( - (keyword) => - !ANNOTATION_KEYWORDS.has(keyword) && - keyword in left && - !sameValue(left[keyword], right[keyword]), - ); + if ( + Object.keys(right).some( + (keyword) => + !ANNOTATION_KEYWORDS.has(keyword) && + keyword in left && + !sameValue(left[keyword], right[keyword]), + ) + ) { + return true; + } + // Keywords the two sides state *separately* can still contradict each other: + // root `{ type: "string" }` under branch `{ const: 1 }` shares no keyword at + // all, yet nothing satisfies both — and the merged declaration would seed an + // immutable `1` into a field the schema rejects. + return !constSatisfiesSiblings({ ...left, ...right }); +} + +/** The JSON type name of a value, in JSON Schema's vocabulary. */ +function jsonTypeOf(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + if (typeof value === "number") { + return Number.isInteger(value) ? "integer" : "number"; + } + return typeof value; +} + +/** + * Whether a merged declaration's `const` is one its own `type` and `enum` admit. + * + * `const` names the single value the schema accepts, so a sibling that excludes + * it leaves nothing satisfiable. An `integer` const satisfies a `number` type, + * which is the one direction JSON Schema widens. + */ +function constSatisfiesSiblings(schema: Record): boolean { + if (!("const" in schema)) return true; + const value = schema.const; + const { type, enum: allowed } = schema; + const actual = jsonTypeOf(value); + const admits = (name: unknown) => + name === actual || (name === "number" && actual === "integer"); + if (typeof type === "string" && !admits(type)) return false; + if (Array.isArray(type) && !type.some(admits)) return false; + if ( + Array.isArray(allowed) && + !allowed.some((member) => sameValue(member, value)) + ) { + return false; + } + return true; } /** From 5b6d68a9b638b11fa379b1f35bf0aeb908ca6bbd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 05:36:30 -0400 Subject: [PATCH 026/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2014=20=E2=80=94=20allOf=20under=20additionalProperties,=20o?= =?UTF-8?q?wn-property=20const=20match?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: the added-names guard is now a shared `addsForbiddenNames` and applies to the `allOf` fold as well as to union branches. A member adding a name a restrictive root `additionalProperties` rejects would otherwise be merged beside the keyword, where the name reads as allowed. - selectBranchIndex tests `Object.hasOwn` before reading a pinned value: a field legally named `constructor` resolved to the inherited one and ruled out the very branch that pins it. Signed-off-by: cliffhall --- clients/web/src/test/core/rootUnion.test.ts | 43 +++++++++++++ core/json/rootUnion.ts | 67 +++++++++++++++------ 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 384703ee3e..05e838cb48 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -147,6 +147,24 @@ describe("resolveRootUnion", () => { expect(base.properties).toBeUndefined(); }); + it("leaves an allOf that adds names a restrictive additionalProperties forbids", () => { + // The root rejects `x` as an additional property; folding the member in + // would move it beside the keyword, where it reads as allowed. + const { base } = resolveRootUnion({ + type: "object", + additionalProperties: false, + allOf: [ + { + type: "object", + properties: { x: { type: "string" } }, + required: ["x"], + }, + ], + }); + expect(base.allOf).toHaveLength(1); + expect(base.properties).toBeUndefined(); + }); + it("merges allOf branches unconditionally", () => { const { base, branches } = resolveRootUnion({ type: "object", @@ -754,6 +772,31 @@ describe("resolveRootUnion", () => { expect(selectBranchIndex(objectPinned, { tag: { a: 2 } })).toBe(1); }); + it("does not read an inherited property as a supplied constant", () => { + // `constructor` is a legal field name; reading the inherited one would + // rule out the very branch that pins it. + const pinnedOnInherited = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: Object.fromEntries([ + ["constructor", { const: "a" }], + ["x", { type: "string" }], + ]), + }, + { + type: "object", + properties: Object.fromEntries([ + ["constructor", { const: "b" }], + ["y", { type: "string" }], + ]), + }, + ] as unknown[], + }).branches; + expect(selectBranchIndex(pinnedOnInherited, { x: "supplied" })).toBe(0); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index fbb8d92eec..07b5786e92 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -367,6 +367,46 @@ function hasDiscriminator( }); } +/** + * Whether a schema's `additionalProperties` rejects names its own `properties` + * does not list. + * + * A schema that constrains nothing is the equivalent of `true`, and that is not + * only the empty object: `{ title: "Extra value" }` is annotation and no more. + * Treating either as restrictive would decline a legal permissive schema. + */ +function restrictsAdditional(schema: RootUnionSchema): boolean { + const additional = schema.additionalProperties; + return ( + additional === false || + (typeof additional === "object" && + additional !== null && + Object.keys(additional).some( + (keyword) => !ANNOTATION_KEYWORDS.has(keyword), + )) + ); +} + +/** + * Whether folding a member into a base would make names the base forbids look + * allowed. + * + * `additionalProperties` constrains whatever its **sibling** `properties` does + * not name, so under a restrictive one the base rejects every field the member + * adds. Merging moves those fields beside the keyword, where they read as + * permitted — and a form built from that submits what the schema forbids. + */ +function addsForbiddenNames( + base: RootUnionSchema, + member: RootUnionSchema, +): boolean { + if (!restrictsAdditional(base)) return false; + const baseNames = propertiesOf(base) ?? {}; + return Object.keys(propertiesOf(member) ?? {}).some( + (name) => !Object.hasOwn(baseNames, name), + ); +} + /** * Every property name the schema's composition members declare, whether or not * the composition could be flattened. @@ -526,7 +566,8 @@ export function resolveRootUnion( if ( branch === null || !isFlattenable(member) || - conflictsWithBase(merged, branch) + conflictsWithBase(merged, branch) || + addsForbiddenNames(merged, branch) ) { return { base: schema as ResolvedSchema, branches: [] }; } @@ -562,25 +603,9 @@ export function resolveRootUnion( // branches add — the original schema admits none of them. Flattening moves // those fields *beside* the keyword, where they would read as allowed, so a // form built from it would submit what the schema forbids. - const additional = base.additionalProperties; - const restrictsAdditional = - additional === false || - // A schema that constrains nothing is the equivalent of `true`, and that is - // not only the empty object: `{ title: "Extra value" }` is annotation and - // no more. Declining on key count alone would recreate the empty form for a - // legal permissive schema, so what counts is whether an assertion is there. - (typeof additional === "object" && - additional !== null && - Object.keys(additional).some( - (keyword) => !ANNOTATION_KEYWORDS.has(keyword), - )); - const baseNames = propertiesOf(base) ?? {}; if ( - restrictsAdditional && branches.some((branch) => - Object.keys(propertiesOf(branch as RootUnionSchema) ?? {}).some( - (name) => !Object.hasOwn(baseNames, name), - ), + addsForbiddenNames(base, branch as RootUnionSchema), ) ) { return { base, branches: [] }; @@ -646,7 +671,11 @@ export function selectBranchIndex( // constant it did not supply is one this identification exists to *seed* — // requiring it would mean a deep link naming `kind` alone matched no branch // whenever the branches also pin, say, a `version`. - const supplied = pinned.filter(([name]) => values[name] !== undefined); + // `hasOwn`: a pinned field legally named `constructor` would otherwise read + // the inherited one as a supplied value and rule its own branch out. + const supplied = pinned.filter( + ([name]) => Object.hasOwn(values, name) && values[name] !== undefined, + ); if ( supplied.length > 0 && // Structural, not reference: a `const` may be an object or an array, and From 09914772eabc18e01428605d340f732a655f2809 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 05:49:43 -0400 Subject: [PATCH 027/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2015=20=E2=80=94=20unevaluable=20const=20siblings,=20$ref=20?= =?UTF-8?q?fields,=20per-branch=20draft=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion: a merged declaration pairing a `const` with an assertion this module cannot evaluate (`minimum`, `pattern`, …) is declined. Proving the conjunction safe is the requirement, not disproving it — `minimum: 10` beside `const: 1` is as unsatisfiable as a type mismatch. - declaresAnyFields counts a `$ref`: its shape is unknown rather than empty, so `anyOf: [{ $ref }, { $ref }]` no longer reports an App tool as input-free and auto-invokes it with `{}`. - SchemaForm keys its draft-holding fields by the active branch as well, so a half-typed `-` or an unparsed JSON draft cannot survive a switch into a same-named field of the incoming branch — with both parent values `undefined`, nothing else tells the field the entity changed. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 33 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 14 ++++++-- clients/web/src/test/core/rootUnion.test.ts | 14 ++++++++ clients/web/src/utils/toolUtils.test.ts | 11 +++++++ core/json/rootUnion.ts | 20 +++++++++++ 5 files changed, 90 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 710deee86a..044302ce69 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2182,6 +2182,39 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(onChange).toHaveBeenCalledWith({ count: 3 }); }); + it("clears an in-progress draft when the branch changes", async () => { + const user = userEvent.setup(); + const schema: InspectorFormSchema = { + type: "object", + anyOf: [ + { + type: "object", + title: "A", + properties: { value: { type: "number", title: "Value" } }, + }, + { + type: "object", + title: "B", + properties: { value: { type: "number", title: "Value" } }, + }, + ], + }; + renderWithMantine( + , + ); + const before = screen.getByLabelText(/Value/) as HTMLInputElement; + // A lone `-` parses to nothing, so the parent value stays `undefined` on + // both sides of the switch — the field's own key is what has to change. + await user.type(before, "-"); + expect(before.value).toBe("-"); + + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "B" })); + expect((screen.getByLabelText(/Value/) as HTMLInputElement).value).toBe( + "", + ); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 46053d1301..8ef66f1160 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -606,6 +606,16 @@ export function SchemaForm({ const properties = effectiveSchema.properties ?? {}; const requiredFields = effectiveSchema.required ?? []; + // The key the draft-holding fields are remounted by. Switching branches is a + // reset for them too: two alternatives may declare the same name with the + // same widget, and a half-typed `-` or an unparsed JSON draft would otherwise + // survive into a field the switch was supposed to clear — with both parent + // values `undefined`, nothing else tells them the entity changed. + const draftKey = + activeBranch === null + ? resetKey + : `${resetKey ?? ""}#${branches.indexOf(activeBranch)}`; + // 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. @@ -867,7 +877,7 @@ export function SchemaForm({ { expect(branches).toEqual([]); }); + it("declines a const paired with an assertion it cannot evaluate", () => { + // `minimum: 10` beside `const: 1` is as unsatisfiable as a type mismatch, + // and proving the conjunction safe is the requirement here. + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { type: "number", minimum: 10 } }, + anyOf: [ + { type: "object", properties: { x: { const: 1 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + }); + it("accepts a const its root type and enum admit", () => { const { branches } = resolveRootUnion({ type: "object", diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index a524ae9751..07c6446f68 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -134,6 +134,17 @@ describe("hasInputFields with root composition (#2123)", () => { ).toBe(true); }); + it("counts a $ref member, whose shape is unknown rather than empty", () => { + expect( + hasInputFields( + tool({ + type: "object", + anyOf: [{ $ref: "#/$defs/Email" }, { $ref: "#/$defs/Sms" }], + }), + ), + ).toBe(true); + }); + it("still reports no fields for a bare object schema", () => { expect(hasInputFields(tool({ type: "object" }))).toBe(false); }); diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 07b5786e92..a9c3168035 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -258,10 +258,26 @@ function jsonTypeOf(value: unknown): string { * it leaves nothing satisfiable. An `integer` const satisfies a `number` type, * which is the one direction JSON Schema widens. */ +const CONST_CHECKABLE = new Set(["const", "type", "enum"]); + function constSatisfiesSiblings(schema: Record): boolean { if (!("const" in schema)) return true; const value = schema.const; const { type, enum: allowed } = schema; + // A `const` can be contradicted by any assertion, and only `type` and `enum` + // are evaluated here — `minimum: 10` beside `const: 1` is as unsatisfiable as + // a type mismatch. Rather than partially evaluate JSON Schema, a merged + // declaration pairing a `const` with an assertion this cannot check is + // declined: proving the conjunction safe is the requirement, not disproving + // it. Annotations are exempt, since they assert nothing. + if ( + Object.keys(schema).some( + (keyword) => + !CONST_CHECKABLE.has(keyword) && !ANNOTATION_KEYWORDS.has(keyword), + ) + ) { + return false; + } const actual = jsonTypeOf(value); const admits = (name: unknown) => name === actual || (name === "number" && actual === "integer"); @@ -420,6 +436,10 @@ export function declaresAnyFields( ): boolean { if (schema === undefined) return false; if (Object.keys(propertiesOf(schema) ?? {}).length > 0) return true; + // A `$ref`'s shape is unknown rather than empty, so it counts. Reporting "no + // fields" for `anyOf: [{ $ref: … }, { $ref: … }]` would auto-invoke an App + // tool with `{}` on the strength of something never read. + if (schema.$ref !== undefined) return true; const members = [ ...(schema.allOf ?? []), ...(schema.anyOf ?? []), From 7792ad71e9da7b5d6ec6f03a522f741d529d8cb3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 06:23:54 -0400 Subject: [PATCH 028/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2016=20=E2=80=94=20tie-breaking,=20const-aware=20gating,=20m?= =?UTF-8?q?alformed=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectBranchIndex no longer stops when several branches share the supplied constant. A branch is dropped only when a supplied value CONTRADICTS one of its constants; the required-field and exclusive-name passes then run over the remaining candidates, so { version: 1, phone: "555" } resolves to the SMS branch instead of falling back to the first. - hasMissingRequiredFields counts only the branches the values could be making (new branchAcceptsValues): { kind: "sms", address: "x" } supplies everything the EMAIL branch requires while contradicting its discriminator, and would otherwise enable a submit the server refuses. - convertToolParameters ignores a malformed property declaration when branches agree on a type, rather than storing a non-schema as the coercion schema. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 21 ++++ clients/web/src/test/core/rootUnion.test.ts | 24 +++++ clients/web/src/utils/jsonUtils.test.ts | 9 ++ clients/web/src/utils/jsonUtils.ts | 10 +- core/json/jsonUtils.ts | 8 +- core/json/rootUnion.ts | 104 ++++++++++++-------- 6 files changed, 133 insertions(+), 43 deletions(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index fea4a72590..c34a42cf94 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -269,6 +269,27 @@ describe("JSON Utils", () => { }); }); + it("ignores a malformed branch declaration when agreeing on a type (#2123)", () => { + const malformedDeclaration: Tool = { + name: "malformed-declaration", + inputSchema: { + type: "object", + anyOf: [ + { type: "object", properties: { count: null as unknown, a: {} } }, + { + type: "object", + properties: { count: { type: "number" }, b: {} }, + }, + ], + }, + }; + // The `null` is not a vote about the type, and must not end up standing + // in for one — the surviving declaration is what coerces. + expect( + convertToolParameters(malformedDeclaration, { count: "3" }), + ).toEqual({ count: 3 }); + }); + it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { const allOfTool: Tool = { name: "allof-tool", diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index b46183e4f4..85ff01cb30 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -811,6 +811,30 @@ describe("resolveRootUnion", () => { expect(selectBranchIndex(pinnedOnInherited, { x: "supplied" })).toBe(0); }); + it("keeps looking when several branches share the supplied constant", () => { + // Both pin `version`, so that constant settles nothing — but `phone` + // belongs to one branch alone and does. + const versioned = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { + version: { const: 1 }, + address: { type: "string" }, + }, + }, + { + type: "object", + properties: { version: { const: 1 }, phone: { type: "string" } }, + }, + ], + }).branches; + expect(selectBranchIndex(versioned, { version: 1, phone: "555" })).toBe( + 1, + ); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 1d2fd8aec5..82614c34f9 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -493,6 +493,15 @@ describe("root composition (#2123)", () => { expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); }); + it("does not count a branch whose discriminator the values contradict", () => { + // `{ kind: "sms", address: … }` supplies everything the EMAIL branch + // requires while carrying a `kind` that branch rejects — and the SMS + // branch it does name is still missing `phone`. + expect( + hasMissingRequiredFields(UNION, { kind: "sms", address: "a@b.c" }), + ).toBe(true); + }); + it("allows submission once one branch is satisfied", () => { expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe( false, diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 823e4f339f..a93f330c8f 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -3,6 +3,7 @@ import { normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; import { + branchAcceptsValues, resolveRootUnion, selectBranchIndex, } from "@inspector/core/json/rootUnion.js"; @@ -266,7 +267,14 @@ export function hasMissingRequiredFields( // direction that matters: it never blocks arguments the schema accepts. const { base, branches } = resolveRootUnion(schema); if (branches.length > 0) { - return branches.every((branch) => hasMissingIn(branch.schema, values)); + // Only the branches the values could actually be making are considered: a + // required-name count alone would let `{ kind: "sms", address: "x" }` look + // like a complete *email* call, whose discriminator it contradicts. + const applicable = branches.filter((branch) => + branchAcceptsValues(branch, values), + ); + if (applicable.length === 0) return true; + return applicable.every((branch) => hasMissingIn(branch.schema, values)); } return hasMissingIn(base, values); } diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 32d5316629..e8b5b9c473 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -178,9 +178,13 @@ function coercionProperties( // specialization of a root property carries the root's keywords too. const declarations = branches .filter((branch) => branch.declaredFields.includes(name)) - .map((branch) => branch.schema.properties?.[name]); + .map((branch) => branch.schema.properties?.[name]) + // A malformed declaration (`properties: { x: null }`) is not a vote about + // the type, and storing it as the coercion schema would put a value that + // is not a schema where one is expected. + .filter((schema) => typeof schema === "object" && schema !== null); const types = new Set(declarations.map((schema) => typeNameOf(schema))); - if (types.size === 1) { + if (types.size === 1 && declarations.length > 0) { Object.defineProperty(properties, name, { value: declarations[0], writable: true, diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index a9c3168035..92247a3378 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -676,7 +676,18 @@ export function selectBranchIndex( branches: RootUnionBranch[], values: Record, ): number | null { - const matches: number[] = []; + const supplied = (name: string) => + // `hasOwn`: a field legally named `constructor` would otherwise read the + // inherited one as a supplied value. + Object.hasOwn(values, name) && values[name] !== undefined; + + // A branch is out of the running as soon as a supplied value disagrees with + // one of its constants; one whose constants agree is a candidate. A constant + // the caller did not supply is one this identification exists to *seed*, so + // it is not evidence either way — which is why a branch that pins nothing + // relevant stays a candidate rather than being ruled in or out. + const candidates: number[] = []; + const agreeing: number[] = []; branches.forEach((branch, index) => { const pinned = Object.entries(propertiesOf(branch.schema) ?? {}) .map( @@ -686,58 +697,45 @@ export function selectBranchIndex( (toBranch(schema) as { const?: unknown } | null)?.const, ] as const, ) - .filter(([, constValue]) => constValue !== undefined); - // Only the pinned names the caller actually supplied are evidence. A - // constant it did not supply is one this identification exists to *seed* — - // requiring it would mean a deep link naming `kind` alone matched no branch - // whenever the branches also pin, say, a `version`. - // `hasOwn`: a pinned field legally named `constructor` would otherwise read - // the inherited one as a supplied value and rule its own branch out. - const supplied = pinned.filter( - ([name]) => Object.hasOwn(values, name) && values[name] !== undefined, + .filter( + ([name, constValue]) => constValue !== undefined && supplied(name), + ); + // Structural, not reference: a `const` may be an object or an array, and + // deep-link arguments arrive as freshly parsed instances that could never + // be `===` the schema's own. + const agrees = pinned.every(([name, constValue]) => + sameValue(values[name], constValue), ); - if ( - supplied.length > 0 && - // Structural, not reference: a `const` may be an object or an array, and - // deep-link arguments arrive as freshly parsed instances that could never - // be `===` the schema's own. - supplied.every(([name, constValue]) => - sameValue(values[name], constValue), - ) - ) { - matches.push(index); - } + if (!agrees) return; + candidates.push(index); + if (pinned.length > 0) agreeing.push(index); }); - if (matches.length === 1) return matches[0]; - if (matches.length > 1) return null; - - // No discriminator settled it. A union need not have one, and values still - // belong to a shape — so fall back to the branch whose own required fields - // the values supply, when exactly one branch's do. Without this, values for - // an undiscriminated branch open the picker on the first branch while the - // required-field gate (which accepts *any* satisfied branch) lets them be - // submitted, so the form shows one shape and sends another. - if (Object.keys(values).length === 0) return null; - const supplied = (name: string) => - Object.hasOwn(values, name) && values[name] !== undefined; - const satisfied = branches.filter((branch) => { + // One branch's discriminator matched and no other's did — the plain case. + if (agreeing.length === 1) return agreeing[0]; + if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0 || Object.keys(values).length === 0) return null; + + // Several branches remain — they share the constant that was supplied, or + // none was. The values still belong to a shape, so keep looking among the + // candidates: first the branch whose own required fields they supply. + const satisfied = candidates.filter((index) => { + const branch = branches[index]!; const required = branch.schema.required ?? []; const own = required.filter((name) => branch.declaredFields.includes(name)); return own.length > 0 && own.every(supplied); }); - if (satisfied.length === 1) return branches.indexOf(satisfied[0]); + if (satisfied.length === 1) return satisfied[0]; - // Nothing is required, or several branches are satisfied. A name only ONE - // alternative declares is still evidence: supplying `phone` where only the + // Then a name only ONE candidate declares: supplying `phone` where only the // SMS branch declares it names that shape as clearly as a discriminator // would. A name more than one declares is ambiguous and says nothing. const exclusiveTo = new Map(); - branches.forEach((branch, index) => { - for (const name of branch.declaredFields) { + for (const index of candidates) { + for (const name of branches[index]!.declaredFields) { exclusiveTo.set(name, exclusiveTo.has(name) ? -1 : index); } - }); + } const named = new Set( Object.keys(values) .filter(supplied) @@ -746,3 +744,29 @@ export function selectBranchIndex( ); return named.size === 1 ? [...named][0] : null; } + +/** + * Whether a branch's own constants are compatible with the values in hand — the + * question "could this call be making this shape". + * + * A required-field check alone is not that question: in an email/SMS union, + * `{ kind: "sms", address: "x" }` supplies everything the *email* branch + * requires while carrying a discriminator that branch rejects, so treating it + * as satisfiable would enable a submit the server refuses. + */ +export function branchAcceptsValues( + branch: RootUnionBranch, + values: Record, +): boolean { + return Object.entries(propertiesOf(branch.schema) ?? {}).every( + ([name, schema]) => { + const constValue = (toBranch(schema) as { const?: unknown } | null) + ?.const; + if (constValue === undefined) return true; + if (!Object.hasOwn(values, name) || values[name] === undefined) { + return true; + } + return sameValue(values[name], constValue); + }, + ); +} From 95030cfdb6a75450c79e1b51df72e796d5c5acc9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 06:38:46 -0400 Subject: [PATCH 029/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2017=20=E2=80=94=20shared=20branch=20narrowing,=20null=20adm?= =?UTF-8?q?issibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The name-based narrowing is extracted as `narrowBySuppliedNames` and used by the CLI coercion as well as the form: an undiscriminated union whose branches type a shared name differently was losing the coercion for every argument, including the ones that identify the branch unambiguously. The type-agreement fallback now also polls only the const-compatible branches. - TUI missingRequiredFields accepts `null` only where the property admits it, matching the web gate: branch fields are rendered optional, so a `default: null` on a non-nullable required field reached the check and the call went out with a value the schema rejects. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 18 +++++++++ clients/tui/src/utils/schemaToForm.ts | 14 +++++++ clients/web/src/test/core/jsonUtils.test.ts | 25 ++++++++++++ core/json/jsonUtils.ts | 44 +++++++++++++++------ core/json/rootUnion.ts | 38 +++++++++++++----- 5 files changed, 117 insertions(+), 22 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index cb9f1df2a0..94f4990c93 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -819,6 +819,24 @@ describe("schemaToForm", () => { expect(missingRequiredFields(schema, {})).toEqual(["constructor"]); }); + it("accepts null only where the schema admits it", () => { + // Branch fields render optional, so a `default: null` on a non-nullable + // required field reaches this check — and `type: "string"` rejects it. + const strict = { + type: "object", + properties: { a: { type: "string" } }, + required: ["a"], + }; + expect(missingRequiredFields(strict, { a: null })).toEqual(["a"]); + + const nullable = { + type: "object", + properties: { a: { type: ["string", "null"] } }, + required: ["a"], + }; + expect(missingRequiredFields(nullable, { a: null })).toEqual([]); + }); + it("checks the root's own required fields when there is no union", () => { const schema = { type: "object", diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 196b735743..03224bddc9 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -4,6 +4,7 @@ import type { FormStructure, FormSection, FormField } from "ink-form"; import { + admitsNull, isStringEnum, normalizeNullableUnion, } from "@inspector/core/json/nullableUnion.js"; @@ -461,12 +462,25 @@ export function missingRequiredFields( branches.length === 0 ? base : branches[selectedBranchIndex(base, branches, rawValues)]!.schema; + const properties = effective.properties ?? {}; return (effective.required ?? []).filter((name) => { // `hasOwn` first: an argument legally named `constructor` would otherwise // resolve to the inherited one and read as supplied, and the call would go // out without it. if (!Object.hasOwn(decoded, name)) return true; const value = decoded[name]; + if (value === null) { + // `null` counts as supplied only where the schema admits it — the same + // test the web gate applies. A branch field is rendered optional here, so + // a `default: null` on a non-nullable one reaches this check and would + // otherwise send a value the schema rejects. + const property = properties[name]; + return ( + typeof property !== "object" || + property === null || + !admitsNull(property) + ); + } return value === undefined || value === ""; }); } diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index c34a42cf94..99d6dc100e 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -156,6 +156,31 @@ describe("JSON Utils", () => { ).toEqual({ kind: "a", value: 3 }); }); + it("identifies the branch from the argument names supplied (#2123)", () => { + const undiscriminated: Tool = { + name: "undiscriminated", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { a: { type: "string" }, value: { type: "number" } }, + }, + { + type: "object", + properties: { b: { type: "string" }, value: { type: "boolean" } }, + }, + ], + }, + }; + // `a` belongs to the first branch alone, so `value` is that branch's + // number — falling straight through to cross-branch type agreement would + // drop the coercion and send "3". + expect( + convertToolParameters(undiscriminated, { a: "x", value: "3" }), + ).toEqual({ a: "x", value: 3 }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index e8b5b9c473..de33de6039 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -1,5 +1,10 @@ import type { Tool } from "@modelcontextprotocol/client"; -import { resolveRootUnion } from "./rootUnion.js"; +import { + narrowBySuppliedNames, + resolveRootUnion, + type RootUnionBranch, + type RootUnionSchema, +} from "./rootUnion.js"; /** * JSON value type used across the inspector project @@ -146,23 +151,34 @@ export function convertParameterValue( * `value=true` into `Number("true")`, i.e. `NaN`. Passing the raw string * through is what this function did for every argument before it existed. */ -function coercionProperties( - base: { properties?: Record }, - branches: { - schema: { properties?: Record }; - declaredFields: string[]; - }[], +function coercionProperties( + base: T, + branches: RootUnionBranch[], params: Record, ): Record { if (branches.length === 0) { return { ...base.properties }; } - const matching = branches.filter((branch) => - matchesConstants(branch.schema.properties ?? {}, params), + // Which branch the call means, from the constants it supplies and then — when + // those leave more than one standing — from the argument NAMES it supplies, + // through the same narrowing the form uses. Without the second step an + // undiscriminated union whose branches type a shared name differently loses + // the coercion for every argument in it, including the ones that identify + // the branch unambiguously. + const candidates = branches + .map((branch, index) => ({ branch, index })) + .filter(({ branch }) => + matchesConstants(branch.schema.properties ?? {}, params), + ) + .map(({ index }) => index); + const selected = narrowBySuppliedNames( + branches, + candidates, + Object.keys(params), ); - if (matching.length === 1) { - return { ...matching[0].schema.properties }; + if (selected !== null) { + return { ...branches[selected]!.schema.properties }; } // `hasOwn`/`fromEntries` rather than `in`/assignment throughout: `properties` @@ -171,12 +187,14 @@ function coercionProperties( const properties: Record = Object.fromEntries( Object.entries(base.properties ?? {}), ); - for (const name of new Set(branches.flatMap((b) => b.declaredFields))) { + const pool = + candidates.length > 0 ? candidates.map((i) => branches[i]!) : branches; + for (const name of new Set(pool.flatMap((b) => b.declaredFields))) { // Only the branches that *declare* the name have an opinion about it — a // branch that merely inherited the root's declaration is not a second, // disagreeing vote. Read through the merged schema so a branch's // specialization of a root property carries the root's keywords too. - const declarations = branches + const declarations = pool .filter((branch) => branch.declaredFields.includes(name)) .map((branch) => branch.schema.properties?.[name]) // A malformed declaration (`properties: { x: null }`) is not a vote about diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 92247a3378..5c77bd2890 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -713,23 +713,44 @@ export function selectBranchIndex( // One branch's discriminator matched and no other's did — the plain case. if (agreeing.length === 1) return agreeing[0]; - if (candidates.length === 1) return candidates[0]; - if (candidates.length === 0 || Object.keys(values).length === 0) return null; // Several branches remain — they share the constant that was supplied, or // none was. The values still belong to a shape, so keep looking among the - // candidates: first the branch whose own required fields they supply. + // candidates by the names that were supplied. + return narrowBySuppliedNames( + branches, + candidates, + Object.keys(values).filter(supplied), + ); +} + +/** + * Choose among candidate branches using only *which* argument names were + * supplied — first the branch whose own required fields they cover, then a name + * only one candidate declares. + * + * Split out because two callers need the same answer from different evidence: + * the form holds typed values, while the CLI holds strings it has not coerced + * yet, and a name is a name in both. A name more than one candidate declares is + * ambiguous and says nothing. + */ +export function narrowBySuppliedNames( + branches: RootUnionBranch[], + candidates: number[], + suppliedNames: string[], +): number | null { + if (candidates.length === 1) return candidates[0]; + if (candidates.length === 0 || suppliedNames.length === 0) return null; + const supplied = new Set(suppliedNames); + const satisfied = candidates.filter((index) => { const branch = branches[index]!; const required = branch.schema.required ?? []; const own = required.filter((name) => branch.declaredFields.includes(name)); - return own.length > 0 && own.every(supplied); + return own.length > 0 && own.every((name) => supplied.has(name)); }); if (satisfied.length === 1) return satisfied[0]; - // Then a name only ONE candidate declares: supplying `phone` where only the - // SMS branch declares it names that shape as clearly as a discriminator - // would. A name more than one declares is ambiguous and says nothing. const exclusiveTo = new Map(); for (const index of candidates) { for (const name of branches[index]!.declaredFields) { @@ -737,8 +758,7 @@ export function selectBranchIndex( } } const named = new Set( - Object.keys(values) - .filter(supplied) + suppliedNames .map((name) => exclusiveTo.get(name)) .filter((index): index is number => index !== undefined && index >= 0), ); From 36cf58fe6c5c34a86fb4b36ba3a2b4cafd5d04d8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 06:55:20 -0400 Subject: [PATCH 030/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2018=20=E2=80=94=20empty-string=20constants,=20nested=20bran?= =?UTF-8?q?ch=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI missingRequiredFields treats an empty string as supplied when the property's own `const` pins it there. The one-option control cannot produce anything else, so reporting the seeded value as missing made such a branch permanently uncallable. - A nested SchemaForm is reset by the branch-aware key too. Two outer alternatives can both carry the same nested object field, and a nested form left mounted across an outer switch kept displaying the inner branch chosen for the other one while the newly seeded values described another shape. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 18 ++++++++ clients/tui/src/utils/schemaToForm.ts | 8 +++- .../groups/SchemaForm/SchemaForm.test.tsx | 45 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 8 +++- 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 94f4990c93..be0d2b1081 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -837,6 +837,24 @@ describe("schemaToForm", () => { expect(missingRequiredFields(nullable, { a: null })).toEqual([]); }); + it("accepts an empty string a const pins the field to", () => { + // The one-option control cannot produce anything else, so reporting the + // seeded value as missing would make the branch permanently uncallable. + const pinnedEmpty = { + type: "object", + properties: { kind: { type: "string", const: "" } }, + required: ["kind"], + }; + expect(missingRequiredFields(pinnedEmpty, { kind: "" })).toEqual([]); + // An ordinary required string is still missing when left blank. + const ordinary = { + type: "object", + properties: { kind: { type: "string" } }, + required: ["kind"], + }; + expect(missingRequiredFields(ordinary, { kind: "" })).toEqual(["kind"]); + }); + it("checks the root's own required fields when there is no union", () => { const schema = { type: "object", diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 03224bddc9..178a908433 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -481,6 +481,12 @@ export function missingRequiredFields( !admitsNull(property) ); } - return value === undefined || value === ""; + if (value === "") { + // A branch may pin its discriminator to the empty string, and the + // one-option control cannot produce anything else — reporting the seeded + // value as missing would make that branch permanently uncallable. + return constOf(properties[name]) !== ""; + } + return value === undefined; }); } diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 044302ce69..9787ced38d 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2215,6 +2215,51 @@ describe("SchemaForm multiline strings (#2042)", () => { ); }); + it("resets a nested form's own branch when the outer branch changes", async () => { + const user = userEvent.setup(); + const nested: InspectorFormSchema = { + type: "object", + properties: { + config: { + type: "object", + title: "Config", + properties: {}, + anyOf: [ + { + type: "object", + title: "Inner A", + properties: { alpha: { type: "string", title: "Alpha" } }, + }, + { + type: "object", + title: "Inner B", + properties: { beta: { type: "string", title: "Beta" } }, + }, + ], + }, + }, + anyOf: [ + { type: "object", title: "Outer A", properties: { x: {} } }, + { type: "object", title: "Outer B", properties: { y: {} } }, + ], + }; + renderWithMantine( + , + ); + const [outer, inner] = screen.getAllByRole("textbox", { + name: /Variant/, + }); + await user.click(inner!); + await user.click(screen.getByRole("option", { name: "Inner B" })); + expect(screen.getByRole("textbox", { name: /Beta/ })).toBeTruthy(); + + await user.click(outer!); + await user.click(screen.getByRole("option", { name: "Outer B" })); + // The nested form is still mounted, so only a changed reset key can stop + // it displaying a branch the newly seeded values do not describe. + expect(screen.getByRole("textbox", { name: /Alpha/ })).toBeTruthy(); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 8ef66f1160..699f2eaa70 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -972,8 +972,12 @@ export function SchemaForm({ handleFieldChange(fieldName, nestedValues) } disabled={disabled} - // Sub-fields belong to the same entity, so they reset with it. - resetKey={resetKey} + // Sub-fields belong to the same entity, so they reset with it — + // and to the branch it is being edited under, since two outer + // alternatives can both carry this field and a nested form left + // mounted across the switch would keep displaying the nested + // branch chosen for the other one. + resetKey={draftKey} // 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) => From 44b2ccd2573a191fd894ce3c7bdb167ab50ecd0a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 07:10:42 -0400 Subject: [PATCH 031/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2019=20=E2=80=94=20empty-string=20const=20in=20the=20web=20r?= =?UTF-8?q?equired=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasMissingRequiredFields treated every empty string as missing, so a required field the schema pins to `""` — rendered read-only, and seeded with the only value it accepts — left Execute and Open App permanently disabled. It now matches the TUI check: an empty string counts as supplied when the property's own `const` pins it there, and an ordinary blank required string still does not. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 22 ++++++++++++++++++++++ clients/web/src/utils/jsonUtils.ts | 8 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 82614c34f9..0178ff12fd 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -488,6 +488,28 @@ describe("root composition (#2123)", () => { ).toBe(values); }); + it("accepts an empty string a const pins the field to", () => { + // Rendered read-only, so treating the seeded value as missing would + // disable submit on a value the user cannot change. + const pinnedEmpty: InspectorFormSchema = { + type: "object", + properties: { kind: { type: "string", const: "" } }, + required: ["kind"], + }; + expect(hasMissingRequiredFields(pinnedEmpty, { kind: "" })).toBe(false); + // An ordinary required string is still missing when left blank. + expect( + hasMissingRequiredFields( + { + type: "object", + properties: { kind: { type: "string" } }, + required: ["kind"], + }, + { kind: "" }, + ), + ).toBe(true); + }); + it("blocks submission while no branch is satisfied", () => { expect(hasMissingRequiredFields(UNION, {})).toBe(true); expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index a93f330c8f..85d0d00cf1 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -292,7 +292,13 @@ function hasMissingIn( const fieldSchema = properties[field]; return fieldSchema === undefined ? true : !admitsNull(fieldSchema); } - return value === undefined || value === ""; + if (value === "") { + // A schema may pin a field to the empty string, and the form renders such + // a field read-only — so reporting the seeded value as missing would + // disable submit on a value the user cannot change (#2123). + return properties[field]?.const !== ""; + } + return value === undefined; }); } From d334710d6f89b74ceca2092e83a5aa6c04aa1f36 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 07:26:49 -0400 Subject: [PATCH 032/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2020=20=E2=80=94=20malformed=20applicators,=20const-null=20u?= =?UTF-8?q?nder=20anyOf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rootUnion reads every composition keyword through `membersOf` and every `required` through `requiredOf`. These schemas describe the wire and each member arrives as `unknown`, so `anyOf: {}` or `required: "x"` really can arrive — and reading them as lists threw, taking all three clients down rather than declining one malformed tool. Both web and TUI required-field checks are guarded the same way. - admitsNull evaluates a sibling `anyOf` beside a `const: null` instead of refusing outright: the union decides in both directions, and `{ const: null, anyOf: [{ type: "null" }] }` admits null as plainly as an all-string union rejects it. Refusing it left a read-only required field seeded with the only value it accepts and submit permanently disabled. Signed-off-by: cliffhall --- clients/tui/src/utils/schemaToForm.ts | 7 ++- .../web/src/test/core/nullableUnion.test.ts | 6 +++ clients/web/src/test/core/rootUnion.test.ts | 29 +++++++++++ clients/web/src/utils/jsonUtils.ts | 5 +- core/json/nullableUnion.ts | 51 ++++++++++--------- core/json/rootUnion.ts | 40 +++++++++++---- 6 files changed, 102 insertions(+), 36 deletions(-) diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 178a908433..dd034cf68a 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -322,7 +322,9 @@ function applyConstants( function buildFields(schema: JsonSchemaObject): FormField[] { const fields: FormField[] = []; const properties = schema.properties || {}; - const required = schema.required || []; + // `Array.isArray`, not `|| []`: a nonconforming server can send + // `required: "x"`, and `.includes` on a string silently matches substrings. + const required = Array.isArray(schema.required) ? schema.required : []; for (const [key, prop] of Object.entries(properties)) { // `properties` values are `unknown` (the SDK schema admits anything), so @@ -463,7 +465,8 @@ export function missingRequiredFields( ? base : branches[selectedBranchIndex(base, branches, rawValues)]!.schema; const properties = effective.properties ?? {}; - return (effective.required ?? []).filter((name) => { + const required = Array.isArray(effective.required) ? effective.required : []; + return required.filter((name) => { // `hasOwn` first: an argument legally named `constructor` would otherwise // resolve to the inherited one and read as supplied, and the call would go // out without it. diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index cbe0ea3432..b1ed03cd3c 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -745,6 +745,12 @@ describe("admitsNull", () => { ); }); + it("admits null when a sibling anyOf branch does", () => { + // The union decides in both directions: an all-string one rejects null, + // and a null branch admits it just as plainly. + expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 85ff01cb30..f498bb99d9 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -664,6 +664,35 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("survives a composition keyword that is not a list", () => { + // These schemas describe the wire, and every member arrives as `unknown` + // — reading `anyOf: {}` as a list would throw and take all three clients + // down rather than declining one malformed tool. + const { base, branches } = resolveRootUnion({ + type: "object", + properties: { a: { type: "string" } }, + anyOf: {} as unknown as unknown[], + }); + expect(branches).toEqual([]); + expect(Object.keys(base.properties ?? {})).toEqual(["a"]); + }); + + it("survives a required that is not a list", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { a: { type: "string" } }, + required: "a", + }, + { type: "object", properties: { b: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toHaveLength(2); + expect(branches[0].schema.required).toBeUndefined(); + }); + it("declines an empty union", () => { expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual( [], diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 85d0d00cf1..4f1838c8ea 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -284,7 +284,10 @@ function hasMissingIn( schema: InspectorFormSchema, values: Record, ): boolean { - const required = schema.required ?? []; + // `Array.isArray`, not `?? []`: these schemas describe the wire, and a + // nonconforming server can send `required: "x"` — which `.some` would throw + // on, taking the whole panel down rather than one malformed tool. + const required = Array.isArray(schema.required) ? schema.required : []; const properties = schema.properties ?? {}; return required.some((field) => { const value = values[field]; diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 1709bd67ca..518c0e8624 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -397,10 +397,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean { // would let the gate accept a `null` the schema forbids. The opaque // applicators (`not`, `allOf`, `oneOf`) are already refused above. if (schema.const === null) { - return ( - schema.anyOf === undefined && - (schema.nullable === true || typeAdmitsNull(schema.type)) - ); + if (schema.nullable !== true && !typeAdmitsNull(schema.type)) return false; + // A sibling `anyOf` is conjunctive with the `const`, so it decides too — + // but it decides in *both* directions: `{ const: null, anyOf: [{ type: + // "null" }] }` admits null just as plainly as an all-string union rejects + // it. Evaluating the branches is what keeps the first case usable, since + // the field is rendered read-only and seeded with the only value it takes. + return schema.anyOf === undefined || anyOfAdmitsNull(schema); } if (schema.nullable === true) { @@ -421,25 +424,27 @@ export function admitsNull(schema: NullableUnionSchema): boolean { } return typeNamesNull(schema.type); } - return ( - schema.anyOf?.some((entry) => { - const branch = toBranch(entry); - 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; - // 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) && - !hasUnevaluatedComposition(branchSchema) - ); - }) ?? false - ); + return anyOfAdmitsNull(schema); +} + +/** Whether some `anyOf` branch is one that admits `null`. */ +function anyOfAdmitsNull(schema: NullableUnionSchema): boolean { + const branches = schema.anyOf; + if (!Array.isArray(branches)) return false; + return branches.some((entry) => { + const branch = toBranch(entry); + if (branch === null || !typeNamesNull(branch.type)) { + return false; + } + // 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. + const branchSchema = branch as NullableUnionSchema; + return ( + !nullExcludedBySiblings(branchSchema) && + !hasUnevaluatedComposition(branchSchema) + ); + }); } /** diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 5c77bd2890..74894efdc5 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -100,6 +100,27 @@ export interface ResolvedRootUnion { branches: RootUnionBranch[]; } +/** + * A composition keyword's members, or `[]` when the value is not a list. + * + * These annotations describe the wire, and the wire is whatever a server sent: + * the web client narrows its schema with a cast and every member arrives as + * `unknown`, so `anyOf: {}` really can reach this module. Reading it as a list + * would throw and take all three clients down with it, which is a worse answer + * than declining to flatten a schema nobody can interpret. + */ +function membersOf(value: readonly unknown[] | undefined): readonly unknown[] { + return Array.isArray(value) ? value : []; +} + +/** A schema's `required`, keeping only the string entries a list-shaped one holds. */ +function requiredOf(schema: RootUnionSchema): string[] { + const { required } = schema; + return Array.isArray(required) + ? required.filter((name): name is string => typeof name === "string") + : []; +} + /** Narrow a composition member to a readable object, or `null` if it isn't one. */ function toBranch(value: unknown): RootUnionSchema | null { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -441,9 +462,9 @@ export function declaresAnyFields( // tool with `{}` on the strength of something never read. if (schema.$ref !== undefined) return true; const members = [ - ...(schema.allOf ?? []), - ...(schema.anyOf ?? []), - ...(schema.oneOf ?? []), + ...membersOf(schema.allOf), + ...membersOf(schema.anyOf), + ...membersOf(schema.oneOf), ]; return members.some((member) => { const branch = toBranch(member); @@ -479,11 +500,10 @@ function mergeBranch( : branchProperty, ]), ]); + const baseRequired = requiredOf(base); const required = [ - ...(base.required ?? []), - ...(branch.required ?? []).filter( - (name) => !(base.required ?? []).includes(name), - ), + ...baseRequired, + ...requiredOf(branch).filter((name) => !baseRequired.includes(name)), ]; // One cast, owned here: a branch member is `unknown` on the wire, so its // property schemas are whatever the server sent however `T` declares them — @@ -576,7 +596,7 @@ export function resolveRootUnion( // composition keywords stay on it, and no union is offered either, since a // branch would otherwise be merged against a base whose constraints are not // all known. - const allOfMembers = schema.allOf ?? []; + const allOfMembers = membersOf(schema.allOf); let merged = schema as ResolvedSchema; for (const member of allOfMembers) { const branch = toBranch(member); @@ -599,7 +619,7 @@ export function resolveRootUnion( return { base, branches: [] }; } const isExclusiveUnion = schema.oneOf !== undefined; - const members = schema.oneOf ?? schema.anyOf ?? []; + const members = membersOf(schema.oneOf ?? schema.anyOf); const branches = members.map(toBranch); if ( branches.length === 0 || @@ -745,7 +765,7 @@ export function narrowBySuppliedNames( const satisfied = candidates.filter((index) => { const branch = branches[index]!; - const required = branch.schema.required ?? []; + const required = requiredOf(branch.schema); const own = required.filter((name) => branch.declaredFields.includes(name)); return own.length > 0 && own.every((name) => supplied.has(name)); }); From 92e485ab82d8a97f241c7ab1ab800cc74fe68847 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 07:41:22 -0400 Subject: [PATCH 033/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2021=20=E2=80=94=20seed=20a=20nested=20object's=20own=20unio?= =?UTF-8?q?n=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectSchemaDefaults recursed only when a nested object declared `properties` directly, so one keeping its fields on a composition branch was skipped — while SchemaForm rendered that branch and displayed its read-only discriminator. The constant was shown and never submitted, and the server rejected the call. The recursion now gates on declaresAnyFields. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 31 +++++++++++++++++++++++++ clients/web/src/utils/jsonUtils.ts | 10 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 0178ff12fd..228f81f32c 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -457,6 +457,37 @@ describe("root composition (#2123)", () => { expect(Object.hasOwn(seeded, "__proto__")).toBe(true); }); + it("seeds defaults from a nested object's own union branch", () => { + // The nested form renders that branch, so its read-only discriminator has + // to reach the submitted values too. + expect( + collectSchemaDefaults({ + type: "object", + properties: { + config: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + }, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + }, + }, + ], + }, + }, + }), + ).toEqual({ config: { kind: "email" } }); + }); + it("re-applies a nested object's constants", () => { // The overlay replaces the whole nested object rather than merging into it, // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 4f1838c8ea..acf5f0a722 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -4,6 +4,7 @@ import { } from "@inspector/core/json/nullableUnion.js"; import { branchAcceptsValues, + declaresAnyFields, resolveRootUnion, selectBranchIndex, } from "@inspector/core/json/rootUnion.js"; @@ -176,7 +177,14 @@ export function collectSchemaDefaults( seed(fieldName, fieldSchema.const); } else if (fieldSchema.default !== undefined) { seed(fieldName, fieldSchema.default); - } else if (fieldSchema.type === "object" && fieldSchema.properties) { + } else if ( + fieldSchema.type === "object" && + // Not `fieldSchema.properties`: a nested object can keep its fields on a + // composition branch too, and `SchemaForm` renders that branch — so + // skipping it here would leave its read-only discriminator displayed but + // never submitted, and the server would reject the call (#2123). + declaresAnyFields(fieldSchema) + ) { const nested = collectSchemaDefaults(fieldSchema); if (Object.keys(nested).length > 0) { seed(fieldName, nested); From 0be450a911f4a603d07c17522a2eb2fa325b6618 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 07:56:15 -0400 Subject: [PATCH 034/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2022=20=E2=80=94=20merge=20deep-link=20args=20with=20default?= =?UTF-8?q?s=20per=20level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The App deep link seeded defaults and spread its `appArgs` over them, which replaces a nested object wholesale: `{ config: { kind: "sms" } }` discarded the nested SMS branch's own defaults, which the form then displayed while the submitted arguments omitted them. New seedSchemaValues merges the two per level, picking each nested branch from the nested values, and the supplied value still wins wherever the two meet. Signed-off-by: cliffhall --- .../views/InspectorView/InspectorView.tsx | 13 ++-- clients/web/src/utils/jsonUtils.test.ts | 60 +++++++++++++++++++ clients/web/src/utils/jsonUtils.ts | 54 +++++++++++++++++ 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 6e845dba4c..43749729c5 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -105,7 +105,7 @@ import { } from "../../../utils/correlateTransportErrors"; import { applySchemaConstants, - collectSchemaDefaults, + seedSchemaValues, toFormSchema, } from "../../../utils/jsonUtils"; import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation"; @@ -1049,10 +1049,13 @@ export function InspectorView({ // link disagreeing with one would otherwise auto-open with a hidden value // contradicting the shape on screen. const appFormSchema = toFormSchema(target.inputSchema) ?? {}; - const formValues = applySchemaConstants(appFormSchema, { - ...collectSchemaDefaults(appFormSchema, deepLink.appArgs ?? {}), - ...deepLink.appArgs, - }); + const formValues = applySchemaConstants( + appFormSchema, + // Merged per level, not with one shallow spread: a nested object in the + // args would otherwise replace the whole seeded object, discarding the + // nested defaults the form goes on displaying. + seedSchemaValues(appFormSchema, deepLink.appArgs ?? {}), + ); // Seed the selection directly rather than routing through // AppsScreen.handleSelect. This deliberately bypasses handleSelect's // no-input-app auto-launch: a deep link must never invoke a tool against diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 228f81f32c..62d0e12318 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -7,6 +7,7 @@ import { collectSchemaDefaults, hasMissingRequiredFields, applySchemaConstants, + seedSchemaValues, } from "./jsonUtils"; import type { InspectorFormSchema } from "./jsonUtils"; @@ -488,6 +489,65 @@ describe("root composition (#2123)", () => { ).toEqual({ config: { kind: "email" } }); }); + describe("seedSchemaValues", () => { + const NESTED: InspectorFormSchema = { + type: "object", + properties: { + config: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + retries: { type: "number", default: 1 }, + }, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + retries: { type: "number", default: 3 }, + }, + }, + ], + }, + }, + }; + + it("keeps a nested branch's defaults beside the supplied values", () => { + // A shallow spread would replace the whole `config` object, so the SMS + // branch's `retries` would be displayed by the form and never submitted. + expect(seedSchemaValues(NESTED, { config: { kind: "sms" } })).toEqual({ + config: { kind: "sms", retries: 3 }, + }); + }); + + it("lets the supplied value win where the two meet", () => { + expect( + seedSchemaValues(NESTED, { config: { kind: "sms", retries: 9 } }), + ).toEqual({ config: { kind: "sms", retries: 9 } }); + }); + + it("does not treat an array as a nested object", () => { + const withArray: InspectorFormSchema = { + type: "object", + properties: { items: { type: "array" } }, + }; + expect(seedSchemaValues(withArray, { items: [1, 2] })).toEqual({ + items: [1, 2], + }); + }); + + it("keeps a supplied argument named __proto__", () => { + const seeded = seedSchemaValues( + { type: "object", properties: {} }, + Object.fromEntries([["__proto__", "kept"]]), + ); + expect(Object.hasOwn(seeded, "__proto__")).toBe(true); + }); + }); + it("re-applies a nested object's constants", () => { // The overlay replaces the whole nested object rather than merging into it, // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index acf5f0a722..5293c6972b 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -194,6 +194,60 @@ export function collectSchemaDefaults( return result; } +/** + * Seed a schema's defaults underneath values a caller already holds, merging + * the two **per level** rather than with one shallow spread. + * + * The App deep link is the caller: it holds `appArgs` and needs the defaults + * the form would otherwise display. A shallow `{ ...defaults, ...args }` + * replaces a nested object wholesale, so `{ config: { kind: "sms" } }` discards + * the nested SMS branch's own defaults — which the form then shows while the + * submitted arguments omit them (#2123). Recursing also lets each nested level + * pick its branch from the nested values, rather than from the top-level ones. + * + * The supplied value always wins where the two meet; only what it does not + * mention is seeded. + */ +export function seedSchemaValues( + schema: InspectorFormSchema, + suppliedValues: Record, +): Record { + const { base, branches } = resolveRootUnion(schema); + const selected = selectBranchIndex(branches, suppliedValues) ?? 0; + const effective = branches[selected]?.schema ?? base; + const properties = effective.properties ?? {}; + + const merged: Record = { + ...collectSchemaDefaults(effective, suppliedValues), + }; + for (const [name, value] of Object.entries(suppliedValues)) { + const fieldSchema = normalizeNullableUnion(properties[name] ?? {}); + const mergedValue = + isPlainObject(value) && + fieldSchema.type === "object" && + declaresAnyFields(fieldSchema) + ? seedSchemaValues( + fieldSchema, + // The nested defaults are re-derived by the recursion, so only the + // supplied half is passed down. + value, + ) + : value; + Object.defineProperty(merged, name, { + value: mergedValue, + writable: true, + enumerable: true, + configurable: true, + }); + } + return merged; +} + +/** Whether a value is a plain object a nested schema could describe. */ +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Overwrite every `const`-pinned field with the value its schema fixes. * From 4cea4e198513a739b86bcabeed0fd39675615750 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 08:31:00 -0400 Subject: [PATCH 035/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2023=20=E2=80=94=20inherited=20constants,=20branch=20null=20?= =?UTF-8?q?forms,=20test=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - matchesConstants checks own-property presence before reading a supplied value, so an absent argument legally named `constructor` no longer reads the inherited one and rules out every branch pinning that name. - anyOfAdmitsNull asks `admitsNull` about each composition-free branch instead of testing `type` alone, so a branch spelling its nullability as `{ const: null }` or `{ nullable: true }` is recognized. Refusing composed branches is what bounds the recursion. - Drop a double cast from the malformed-applicator fixture. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 30 +++++++++++++++++++ .../web/src/test/core/nullableUnion.test.ts | 8 +++++ clients/web/src/test/core/rootUnion.test.ts | 5 +++- core/json/jsonUtils.ts | 3 ++ core/json/nullableUnion.ts | 22 +++++++------- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 99d6dc100e..f48c1234bc 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -181,6 +181,36 @@ describe("JSON Utils", () => { ).toEqual({ a: "x", value: 3 }); }); + it("does not read an inherited property as a supplied constant (#2123)", () => { + const pinnedOnInherited: Tool = { + name: "pinned-on-inherited", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: Object.fromEntries([ + ["constructor", { const: "a" }], + ["count", { type: "number" }], + ]), + }, + { + type: "object", + properties: Object.fromEntries([ + ["constructor", { const: "b" }], + ["other", { type: "string" }], + ]), + }, + ], + }, + }; + // `constructor` was not supplied, so it rules nothing out — `count` + // belongs to the first branch alone and settles it. + expect(convertToolParameters(pinnedOnInherited, { count: "3" })).toEqual({ + count: 3, + }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index b1ed03cd3c..d7090864eb 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -751,6 +751,14 @@ describe("admitsNull", () => { expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true); }); + it("recognizes a branch that spells its nullability another way", () => { + // The branch admits null through `const`, not through `type`. + expect(admitsNull({ const: null, anyOf: [{ const: null }] })).toBe(true); + expect(admitsNull({ const: null, anyOf: [{ nullable: true }] })).toBe( + true, + ); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index f498bb99d9..fc795624e3 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -668,10 +668,13 @@ describe("resolveRootUnion", () => { // These schemas describe the wire, and every member arrives as `unknown` // — reading `anyOf: {}` as a list would throw and take all three clients // down rather than declining one malformed tool. + // A single assertion, from an `unknown`-typed value: the point is a wire + // shape TypeScript would never produce, not a cast chain. + const malformed: unknown = {}; const { base, branches } = resolveRootUnion({ type: "object", properties: { a: { type: "string" } }, - anyOf: {} as unknown as unknown[], + anyOf: malformed as unknown[], }); expect(branches).toEqual([]); expect(Object.keys(base.properties ?? {})).toEqual(["a"]); diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index de33de6039..aa5030c621 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -240,6 +240,9 @@ function matchesConstants( if (typeof schema !== "object" || schema === null) return true; const constValue = (schema as { const?: unknown }).const; if (constValue === undefined) return true; + // `hasOwn`: an absent argument legally named `constructor` would otherwise + // read the inherited one and rule out every branch that pins that name. + if (!Object.hasOwn(params, name)) return true; const supplied = params[name]; return supplied === undefined || supplied === String(constValue); }); diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 518c0e8624..e935b77c19 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -433,17 +433,19 @@ function anyOfAdmitsNull(schema: NullableUnionSchema): boolean { if (!Array.isArray(branches)) return false; return branches.some((entry) => { const branch = toBranch(entry); - if (branch === null || !typeNamesNull(branch.type)) { - return false; - } - // 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. + if (branch === null) return false; const branchSchema = branch as NullableUnionSchema; - return ( - !nullExcludedBySiblings(branchSchema) && - !hasUnevaluatedComposition(branchSchema) - ); + // A nested union or applicator inside a branch is as opaque here as one on + // the wrapper, and refusing it also bounds this recursion: what remains is + // a composition-free schema, which `admitsNull` answers without reaching + // back into this function. + if (hasUnevaluatedComposition(branchSchema)) return false; + // Asked through `admitsNull` rather than by testing `type` alone, so a + // branch spelling its nullability another way — `{ const: null }`, + // `{ nullable: true }` — is recognized the same way the wrapper's own + // forms are, and an unsatisfiable `{ type: "null", const: "x" }` is still + // rejected by the sibling check inside it. + return admitsNull(branchSchema); }); } From 1cebe717613a542fc68830c04151582ec290094c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 08:55:51 -0400 Subject: [PATCH 036/213] fix(smoke): route the Chromium tier through ENGINE_SMOKES too I claimed ENGINE_SMOKES was the single home for "which smokes are engine-sensitive". It was not: npm run smoke still hard-coded the same three, so a fourth entry would have reached the Firefox and on-demand runs and silently skipped the DEFAULT Chromium run -- which is the one GitHub CI executes. The exact drift the list was introduced to remove, left in the one tier that matters most. npm run smoke now ends in smoke:web:chromium, which goes through the runner like the other two tiers. Two things fall out of that: - The Chromium tier passes its engine as a literal argument, so an ambient SMOKE_BROWSER can no longer redirect it. Without that, SMOKE_BROWSER=firefox npm run ci would have run Firefox twice and never exercised Chromium at all -- a gate silently testing something other than what it claims. - run-engine-smokes.test.mjs now asserts that no tier names an individual smoke, that both gated tiers name their engine explicitly, and that the on-demand entry point is the one that follows the environment. This is the durable half: a tier running fewer smokes is invisible at runtime, because it still passes. Mutation-verified -- restoring the old hard- coded chain fails the test. Docs updated to state the wider claim rather than the one that was true of only two tiers. Addresses the suppressed comment on Copilot review 5026921914 of #2133. Signed-off-by: cliffhall --- AGENTS.md | 2 +- package.json | 3 +- scripts/run-engine-smokes.mjs | 11 ++++--- scripts/run-engine-smokes.test.mjs | 50 +++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c47e2c190..d8f2b3f734 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -946,7 +946,7 @@ SMOKE_BROWSER=webkit npm run smoke:web:engine # any engine, on demand SMOKE_BROWSER=firefox npm run smoke:web:app # one smoke, one engine ``` -**Firefox runs in the local pre-push gate (`npm run ci`) and NOT in GitHub CI.** That split is the whole design — see the gate bullet below. `ENGINE_SMOKES` in `scripts/run-engine-smokes.mjs` is the single list of which smokes are engine-sensitive; add a fourth there and every engine picks it up. `smoke:web:firefox` passes the engine as an argument rather than relying on the ambient `SMOKE_BROWSER`, so the gate cannot be silently redirected to another engine by a stray variable — verified, an explicit argument beats it. +**Firefox runs in the local pre-push gate (`npm run ci`) and NOT in GitHub CI.** That split is the whole design — see the gate bullet below. `ENGINE_SMOKES` in `scripts/run-engine-smokes.mjs` is the single list of which smokes are engine-sensitive, and **every tier reads it** — the default Chromium run inside `npm run smoke`, the Firefox pass in the gate, and the on-demand command. Add a fourth there and all three pick it up. A tier that enumerated the smokes itself would silently run fewer and still pass, so `run-engine-smokes.test.mjs` asserts that none of them does. `smoke:web:firefox` passes the engine as an argument rather than relying on the ambient `SMOKE_BROWSER`, so the gate cannot be silently redirected to another engine by a stray variable — verified, an explicit argument beats it. Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** — `resolveBrowserName`, `loadBrowser`, and the `attachPageDiagnostics` / `FATAL_CONSOLE` split the smokes had each hand-rolled. Reach for it rather than launching Playwright in a new script. diff --git a/package.json b/package.json index eba1ca4cb4..827715967b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "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 && npm run smoke:web:elicit", + "smoke": "npm run smoke:launcher && npm run smoke:cli && npm run smoke:tui && npm run smoke:web && npm run smoke:web:chromium", "smoke:cli": "node scripts/smoke-cli.mjs", "smoke:tui": "node scripts/smoke-tui.mjs", "smoke:web": "node scripts/smoke-web.mjs", @@ -74,6 +74,7 @@ "smoke:web:app": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-app.mjs", "smoke:web:elicit": "node scripts/install-smoke-browser.mjs && node scripts/smoke-web-elicitation.mjs", "smoke:web:engine": "node scripts/run-engine-smokes.mjs", + "smoke:web:chromium": "node scripts/run-engine-smokes.mjs chromium", "smoke:web:firefox": "node scripts/run-engine-smokes.mjs firefox", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/install-smoke-browser.mjs chromium && node scripts/pack-and-verify.mjs", diff --git a/scripts/run-engine-smokes.mjs b/scripts/run-engine-smokes.mjs index 29c041a703..71883b54fa 100644 --- a/scripts/run-engine-smokes.mjs +++ b/scripts/run-engine-smokes.mjs @@ -8,10 +8,13 @@ * from Node does, on every platform — the same reason `install-smoke-browser` * exists rather than an inline shell expansion. * - * It also gives the smoke list ONE home. It used to be an `&&` chain in - * package.json alongside a second chain in the workflow; adding a fourth smoke - * meant remembering both. Now `ENGINE_SMOKES` is the list, and both the - * on-demand command and the pre-push gate run through it. + * It also gives the smoke list ONE home. `ENGINE_SMOKES` is that list, and + * EVERY tier reads it — the default Chromium run inside `npm run smoke` + * (`smoke:web:chromium`), the Firefox pass in the pre-push gate + * (`smoke:web:firefox`), and the on-demand `smoke:web:engine`. Getting that + * wrong is silent: a tier that enumerated the smokes itself would simply run + * fewer of them and still pass, so `run-engine-smokes.test.mjs` asserts that no + * tier names an individual smoke. * * Usage: `node scripts/run-engine-smokes.mjs [engine]`. With no argument the * engine comes from `SMOKE_BROWSER` (default `chromium`). diff --git a/scripts/run-engine-smokes.test.mjs b/scripts/run-engine-smokes.test.mjs index ca8516c6c3..47197d1786 100644 --- a/scripts/run-engine-smokes.test.mjs +++ b/scripts/run-engine-smokes.test.mjs @@ -12,12 +12,15 @@ */ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it } from "node:test"; import { ENGINE_SMOKES } from "./run-engine-smokes.mjs"; const scriptDir = import.meta.dirname; +const scripts = JSON.parse( + readFileSync(join(scriptDir, "..", "package.json"), "utf8"), +).scripts; describe("ENGINE_SMOKES", () => { it("names every browser-driven smoke, and each one exists", () => { @@ -43,3 +46,48 @@ describe("ENGINE_SMOKES", () => { assert.equal(ENGINE_SMOKES[0], "smoke-web-browser.mjs"); }); }); + +describe("every engine tier consumes ENGINE_SMOKES", () => { + // The point of the list is defeated if any tier enumerates the smokes itself. + // `npm run smoke` used to do exactly that (Copilot, #2133): a fourth entry in + // ENGINE_SMOKES would have reached the Firefox and on-demand runs and silently + // skipped the DEFAULT Chromium run — which is the one GitHub CI executes. That + // is invisible at runtime, because a tier running fewer smokes still passes. + + it("the Chromium tier goes through the runner, not its own list", () => { + assert.match(scripts["smoke:web:chromium"], /run-engine-smokes\.mjs/); + assert.match(scripts.smoke, /smoke:web:chromium/); + for (const individual of [ + "smoke:web:browser", + "smoke:web:app", + "smoke:web:elicit", + ]) { + assert.ok( + !scripts.smoke.includes(individual), + `\`smoke\` names ${individual} directly — it must route through ` + + "`smoke:web:chromium` so every tier reads one list", + ); + } + }); + + it("the Firefox tier goes through the runner and is in the pre-push gate", () => { + assert.match(scripts["smoke:web:firefox"], /run-engine-smokes\.mjs/); + assert.match(scripts.ci, /smoke:web:firefox/); + }); + + it("each gated tier names its engine explicitly rather than reading the env", () => { + // An ambient SMOKE_BROWSER must not be able to redirect a gate: without the + // literal engine, `SMOKE_BROWSER=firefox npm run ci` would run Firefox twice + // and never exercise Chromium at all. + assert.match( + scripts["smoke:web:chromium"], + /run-engine-smokes\.mjs chromium$/, + ); + assert.match( + scripts["smoke:web:firefox"], + /run-engine-smokes\.mjs firefox$/, + ); + // The on-demand entry point is the one that SHOULD follow the environment. + assert.match(scripts["smoke:web:engine"], /run-engine-smokes\.mjs$/); + }); +}); From 54c5e12b2b9ab6402794df317bc4638b91549e5a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 09:09:38 -0400 Subject: [PATCH 037/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2024=20=E2=80=94=20a=20discriminator=20must=20be=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A distinct `const` does not make `oneOf` alternatives mutually exclusive unless the property carrying it is required: two branches pinning an OPTIONAL `kind` both match `{}`, so arguments omitting it satisfy more than one alternative — exactly what `oneOf` forbids. The property must now be required by the root or by every branch. Also makes `propertiesOf` total (an unreadable `properties` reads as empty, with a separate predicate for the one caller that must tell them apart), which removes a row of unreachable `?? {}` fallbacks, and covers the value/type comparisons the const checks rest on. Signed-off-by: cliffhall --- README.md | 2 +- clients/web/src/test/core/jsonUtils.test.ts | 4 + clients/web/src/test/core/rootUnion.test.ts | 112 ++++++++++++++++++++ core/json/rootUnion.ts | 86 ++++++++------- 4 files changed, 167 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index c8296ebd6c..8f28891f5b 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ What it declines to flatten is as deliberate as what it flattens, and every case - **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker. - **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all. - **A composition member stating anything the merge cannot apply.** Only `type`, `properties` and `required` are folded in, so a member carrying a nested `allOf`/`anyOf`, a `not`, an `additionalProperties`, or a `$ref` would have that constraint erased along with the keyword — turning an unsatisfiable schema (`allOf: [false, …]` admits nothing) into a fillable form. `allOf` members are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root. -- **A `oneOf` whose alternatives are not mutually exclusive.** `oneOf` demands that *exactly one* alternative match, which flattening cannot preserve — the branches are offered as if any would do. It is only safe with a discriminator (a property every branch pins to a `const` of its own), so an undiscriminated `oneOf` is declined. `anyOf` makes no such claim and is offered either way. +- **A `oneOf` whose alternatives are not mutually exclusive.** `oneOf` demands that *exactly one* alternative match, which flattening cannot preserve — the branches are offered as if any would do. It is only safe with a discriminator: a property every branch pins to a `const` of its own **and requires**, since an optional one leaves `{}` matching every branch. An undiscriminated `oneOf` is declined; `anyOf` makes no such claim and is offered either way. - **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. An empty schema (`{}`) constrains nothing and is treated as permissive. - **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for. - **`not`**, which is not interpreted at all: there is no faithful form for "anything except this". diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index f48c1234bc..486f902b4a 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -135,6 +135,9 @@ describe("JSON Utils", () => { kind: { type: "string", const: "a" }, value: { type: "number" }, }, + // Required, or the alternatives are not exclusive and the + // resolver declines the `oneOf` outright. + required: ["kind"], }, { type: "object", @@ -142,6 +145,7 @@ describe("JSON Utils", () => { kind: { type: "string", const: "b" }, value: { type: "boolean" }, }, + required: ["kind"], }, ], }, diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index fc795624e3..c408680af8 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -444,6 +444,81 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + describe("values it compares and types it recognizes", () => { + it("offers a branch whose type list names object", () => { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: ["object", "null"], + properties: { a: { type: "string" } }, + }, + { type: "object", properties: { b: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toHaveLength(2); + }); + + it("compares array-valued constants structurally", () => { + const { branches } = resolveRootUnion({ + type: "object", + required: ["tag"], + oneOf: [ + { + type: "object", + properties: { tag: { const: [1, 2] }, x: { type: "string" } }, + }, + { + type: "object", + properties: { tag: { const: [1, 2] }, y: { type: "string" } }, + }, + ], + }); + // The same value twice, so the alternatives are not exclusive. + expect(branches).toEqual([]); + }); + + it("treats a keyword explicitly set to undefined as a disagreement", () => { + const { branches } = resolveRootUnion({ + type: "object", + properties: { x: { minimum: undefined } }, + anyOf: [ + { type: "object", properties: { x: { minimum: 1 } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + }); + + it("recognizes each JSON type when checking a const", () => { + const accepts = (type: unknown, constValue: unknown) => + resolveRootUnion({ + type: "object", + properties: { x: { type } as unknown }, + anyOf: [ + { type: "object", properties: { x: { const: constValue } } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }).branches.length > 0; + + expect(accepts("null", null)).toBe(true); + expect(accepts("array", [1])).toBe(true); + expect(accepts("number", 1)).toBe(true); + expect(accepts(["string", "integer"], 1)).toBe(true); + expect(accepts("integer", 1.5)).toBe(false); + expect(accepts(["string", "boolean"], 1)).toBe(false); + }); + + it("emits no properties when neither side declares any", () => { + const { base } = resolveRootUnion({ + type: "object", + allOf: [{ type: "object", required: ["a"] }], + }); + expect(base.properties).toBeUndefined(); + expect(base.required).toEqual(["a"]); + }); + }); + describe("branch labels", () => { it("uses the branch's own title first", () => { const { branches } = resolveRootUnion({ @@ -597,6 +672,43 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("declines a oneOf whose discriminator is optional", () => { + // Two branches pinning an OPTIONAL `kind` both match `{}`, so arguments + // omitting it satisfy more than one alternative. + const { branches } = resolveRootUnion({ + type: "object", + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, x: { type: "string" } }, + }, + { + type: "object", + properties: { kind: { const: "b" }, y: { type: "string" } }, + }, + ], + }); + expect(branches).toEqual([]); + }); + + it("accepts a discriminator the root requires", () => { + const { branches } = resolveRootUnion({ + type: "object", + required: ["kind"], + oneOf: [ + { + type: "object", + properties: { kind: { const: "a" }, x: { type: "string" } }, + }, + { + type: "object", + properties: { kind: { const: "b" }, y: { type: "string" } }, + }, + ], + }); + expect(branches).toHaveLength(2); + }); + it("declines a oneOf whose named discriminator does not distinguish", () => { const { branches } = resolveRootUnion({ type: "object", diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 74894efdc5..ad9f31dadb 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -136,17 +136,27 @@ function admitsObject(schema: RootUnionSchema): boolean { return Array.isArray(type) ? type.includes("object") : type === "object"; } -/** A readable `properties` map, or `null` when the value is not one. */ -function propertiesOf(schema: RootUnionSchema): Record | null { +/** Whether a schema's `properties` is a readable map rather than junk. */ +function hasReadableProperties(schema: RootUnionSchema): boolean { const { properties } = schema; - if ( - typeof properties !== "object" || - properties === null || - Array.isArray(properties) - ) { - return null; - } - return properties; + return ( + typeof properties === "object" && + properties !== null && + !Array.isArray(properties) + ); +} + +/** + * A schema's `properties`, or an empty map when it has none — or when what it + * has is not a map at all, which a wire schema really can be. Total by design: + * every caller but {@link isOfferable} wants to enumerate whatever is there, + * and a nullable return would leave each of them carrying a `?? {}` that + * nothing can reach. + */ +function propertiesOf(schema: RootUnionSchema): Record { + return hasReadableProperties(schema) + ? (schema.properties as Record) + : {}; } /** @@ -166,10 +176,9 @@ function propertiesOf(schema: RootUnionSchema): Record | null { * as a fillable form would offer a call that cannot be valid. */ function isOfferable(branch: RootUnionSchema): boolean { - const properties = propertiesOf(branch); return ( - properties !== null && - Object.keys(properties).length > 0 && + hasReadableProperties(branch) && + Object.keys(propertiesOf(branch)).length > 0 && admitsObject(branch) ); } @@ -364,8 +373,8 @@ function conflictsWithBase( base: RootUnionSchema, branch: RootUnionSchema, ): boolean { - const baseProperties = propertiesOf(base) ?? {}; - const branchProperties = propertiesOf(branch) ?? {}; + const baseProperties = propertiesOf(base); + const branchProperties = propertiesOf(branch); return Object.entries(branchProperties).some( ([name, branchProperty]) => // `hasOwn`, not `in`: `properties` is a JSON record, so `constructor` and @@ -388,12 +397,19 @@ function conflictsWithBase( function hasDiscriminator( members: RootUnionSchema[], named: string | undefined, + rootRequired: string[], ): boolean { - const first = propertiesOf(members[0] ?? {}) ?? {}; + const first = propertiesOf(members[0] ?? {}); const candidates = named !== undefined ? [named] : Object.keys(first); return candidates.some((name) => { + // Required, or it discriminates nothing: two branches pinning an OPTIONAL + // `kind` to different constants both match `{}`, so arguments omitting it + // satisfy more than one alternative — exactly what `oneOf` forbids. const constants = members.map((member) => { - const property = toBranch((propertiesOf(member) ?? {})[name]) as { + if (!rootRequired.includes(name) && !requiredOf(member).includes(name)) { + return undefined; + } + const property = toBranch(propertiesOf(member)[name]) as { const?: unknown; } | null; return property?.const; @@ -438,8 +454,8 @@ function addsForbiddenNames( member: RootUnionSchema, ): boolean { if (!restrictsAdditional(base)) return false; - const baseNames = propertiesOf(base) ?? {}; - return Object.keys(propertiesOf(member) ?? {}).some( + const baseNames = propertiesOf(base); + return Object.keys(propertiesOf(member)).some( (name) => !Object.hasOwn(baseNames, name), ); } @@ -456,7 +472,7 @@ export function declaresAnyFields( schema: RootUnionSchema | undefined, ): boolean { if (schema === undefined) return false; - if (Object.keys(propertiesOf(schema) ?? {}).length > 0) return true; + if (Object.keys(propertiesOf(schema)).length > 0) return true; // A `$ref`'s shape is unknown rather than empty, so it counts. Reporting "no // fields" for `anyOf: [{ $ref: … }, { $ref: … }]` would auto-invoke an App // tool with `{}` on the strength of something never read. @@ -485,8 +501,8 @@ function mergeBranch( base: T, branch: RootUnionSchema, ): ResolvedSchema { - const baseProperties = propertiesOf(base) ?? {}; - const branchProperties = propertiesOf(branch) ?? {}; + const baseProperties = propertiesOf(base); + const branchProperties = propertiesOf(branch); // Built through `fromEntries` rather than by assignment: a property named // `__proto__` is a legal argument name, and assigning it would invoke the // legacy prototype setter instead of creating an own property — losing the @@ -544,7 +560,7 @@ function branchLabel( if (typeof branch.title === "string" && branch.title.trim() !== "") { return branch.title; } - const properties = propertiesOf(branch) ?? {}; + const properties = propertiesOf(branch); const constOf = (name: string): string | null => { const property = toBranch(properties[name]) as { const?: unknown } | null; const value = property?.const; @@ -662,6 +678,7 @@ export function resolveRootUnion( !hasDiscriminator( branches as RootUnionSchema[], schema.discriminator?.propertyName, + requiredOf(base), ) ) { return { base, branches: [] }; @@ -677,7 +694,7 @@ export function resolveRootUnion( // `properties`/`required` off the branch, so the merge stays that way. schema: mergeBranch(base, branch), label: branchLabel(branch, index, discriminatorProperty), - declaredFields: Object.keys(propertiesOf(branch) ?? {}), + declaredFields: Object.keys(propertiesOf(branch)), })), }; } @@ -709,7 +726,7 @@ export function selectBranchIndex( const candidates: number[] = []; const agreeing: number[] = []; branches.forEach((branch, index) => { - const pinned = Object.entries(propertiesOf(branch.schema) ?? {}) + const pinned = Object.entries(propertiesOf(branch.schema)) .map( ([name, schema]) => [ @@ -798,15 +815,12 @@ export function branchAcceptsValues( branch: RootUnionBranch, values: Record, ): boolean { - return Object.entries(propertiesOf(branch.schema) ?? {}).every( - ([name, schema]) => { - const constValue = (toBranch(schema) as { const?: unknown } | null) - ?.const; - if (constValue === undefined) return true; - if (!Object.hasOwn(values, name) || values[name] === undefined) { - return true; - } - return sameValue(values[name], constValue); - }, - ); + return Object.entries(propertiesOf(branch.schema)).every(([name, schema]) => { + const constValue = (toBranch(schema) as { const?: unknown } | null)?.const; + if (constValue === undefined) return true; + if (!Object.hasOwn(values, name) || values[name] === undefined) { + return true; + } + return sameValue(values[name], constValue); + }); } From ab3f8c779a9b71e45e07a1f4eca86baea8600481 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 09:30:36 -0400 Subject: [PATCH 038/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2025=20=E2=80=94=20names=20before=20constants,=20unrenderabl?= =?UTF-8?q?e=20properties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selectBranchIndex asks the supplied NAMES first and falls back to a lone agreeing constant only when they settle nothing. A matching `const` does not identify a branch while another candidate leaves that property unpinned: `{ kind: "email", phone: "555" }` agrees with an email branch whose `address` is missing while satisfying a phone branch outright. - isOfferable requires every property VALUE to be readable — JSON Schema's boolean form is fine, a `null` or an array is not a schema at all and the web form dereferences one on the way to choosing a widget. Such a branch is declined rather than handed on to crash a tool panel that would otherwise have rendered. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 10 ++-- clients/web/src/test/core/jsonUtils.test.ts | 13 ++--- clients/web/src/test/core/rootUnion.test.ts | 56 +++++++++++++++++++++ core/json/rootUnion.ts | 38 ++++++++++---- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index be0d2b1081..ec8d3aeb6c 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -743,9 +743,10 @@ describe("schemaToForm", () => { expect(decodeFormValues({ type: "object" }, values)).toBe(values); }); - it("ignores a malformed property declaration when restoring constants", () => { - // `properties` values are `unknown`; a `null` entry must not throw on the - // way out of the form any more than it does on the way in. + it("declines a union carrying a malformed property declaration", () => { + // `properties` values are `unknown`; a `null` entry is not a schema, so + // the union is declined — and, the point of the test, nothing throws on + // the way out of the form any more than on the way in. const schema = { type: "object", anyOf: [ @@ -756,9 +757,10 @@ describe("schemaToForm", () => { { type: "object", properties: { kind: { const: "b" } } }, ] as unknown[], }; + // No union, so the form values pass through as they are. expect( decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }), - ).toEqual({ kind: "a" }); + ).toEqual({ __variant: "0", __b0__kind: "tampered" }); }); }); diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 486f902b4a..6cd4c5f3d9 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -291,11 +291,12 @@ describe("JSON Utils", () => { ], }, }; - // A `properties: { broken: null }` entry must not throw, and a branch is - // still identifiable by the discriminator that *is* well-formed. + // A `properties: { broken: null }` entry must not throw. The union is + // declined — a `null` is not a schema, and handing it on would crash a + // renderer — so nothing is coerced and the strings pass through. expect( convertToolParameters(malformed, { kind: "a", value: "3" }), - ).toEqual({ kind: "a", value: 3 }); + ).toEqual({ kind: "a", value: "3" }); }); it("falls back to the branch-agreement path when no constant is supplied (#2123)", () => { @@ -342,11 +343,11 @@ describe("JSON Utils", () => { ], }, }; - // The `null` is not a vote about the type, and must not end up standing - // in for one — the surviving declaration is what coerces. + // The `null` makes the whole union unofferable, so nothing is coerced — + // and, the point of the test, nothing throws either. expect( convertToolParameters(malformedDeclaration, { count: "3" }), - ).toEqual({ count: 3 }); + ).toEqual({ count: "3" }); }); it("coerces a value whose schema lives on a root allOf branch (#2123)", () => { diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index c408680af8..bc29fbfed6 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -748,6 +748,35 @@ describe("resolveRootUnion", () => { expect(branches).toEqual([]); }); + it("declines a member carrying a property that is not a schema", () => { + // A `null` or an array is not a schema, and the web form dereferences + // one on the way to choosing a widget — so the branch is declined rather + // than handed on to crash a tool panel. + for (const property of [null, [1]] as unknown[]) { + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + EMAIL, + { type: "object", properties: { broken: property } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + } + }); + + it("offers a member carrying a boolean property schema", () => { + // JSON Schema's boolean form is legal and answers every keyword lookup + // with `undefined`, so it renders through the JSON fallback harmlessly. + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { type: "object", properties: { anything: true } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toHaveLength(2); + }); + it("declines a member whose properties are not an object", () => { // Members arrive as `unknown`, so this is reachable and must not throw. expect( @@ -979,6 +1008,33 @@ describe("resolveRootUnion", () => { ); }); + it("prefers the branch the supplied names satisfy over a matching constant", () => { + // `{ kind: "email", phone: "555" }` agrees with the email branch's + // discriminator while missing its `address`, and satisfies the phone + // branch outright — the picker must show the one that can be called. + const mixed = resolveRootUnion({ + type: "object", + anyOf: [ + { + type: "object", + properties: { + kind: { const: "email" }, + address: { type: "string" }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { phone: { type: "string" } }, + required: ["phone"], + }, + ], + }).branches; + expect(selectBranchIndex(mixed, { kind: "email", phone: "555" })).toBe(1); + // …and the constant still decides when the names settle nothing. + expect(selectBranchIndex(mixed, { kind: "email" })).toBe(0); + }); + it("reports none when the values identify nothing", () => { expect(selectBranchIndex(branches, {})).toBeNull(); expect(selectBranchIndex(branches, { kind: "other" })).toBeNull(); diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index ad9f31dadb..25059afe70 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -176,10 +176,23 @@ function propertiesOf(schema: RootUnionSchema): Record { * as a fillable form would offer a call that cannot be valid. */ function isOfferable(branch: RootUnionSchema): boolean { + if (!hasReadableProperties(branch) || !admitsObject(branch)) return false; + const properties = Object.values(propertiesOf(branch)); return ( - hasReadableProperties(branch) && - Object.keys(propertiesOf(branch)).length > 0 && - admitsObject(branch) + properties.length > 0 && + // Every value has to be something a renderer can read. JSON Schema's + // boolean form is legal and harmless — `true`/`false` answer every keyword + // lookup with `undefined` — but a `null` or an array is not a schema at + // all, and the web form dereferences one on the way to choosing a widget. + // A branch carrying one is declined rather than handed on to crash a tool + // panel that would otherwise have rendered. + properties.every( + (property) => + typeof property === "boolean" || + (typeof property === "object" && + property !== null && + !Array.isArray(property)), + ) ); } @@ -748,17 +761,22 @@ export function selectBranchIndex( if (pinned.length > 0) agreeing.push(index); }); - // One branch's discriminator matched and no other's did — the plain case. - if (agreeing.length === 1) return agreeing[0]; - - // Several branches remain — they share the constant that was supplied, or - // none was. The values still belong to a shape, so keep looking among the - // candidates by the names that were supplied. - return narrowBySuppliedNames( + // What the supplied NAMES say comes first: a matching constant does not + // identify a branch on its own while another candidate leaves that property + // unpinned — `{ kind: "email", phone: "555" }` agrees with an email branch + // whose `address` is missing while satisfying a phone branch outright, and + // the picker must show the one that could actually be called. + const narrowed = narrowBySuppliedNames( branches, candidates, Object.keys(values).filter(supplied), ); + if (narrowed !== null) return narrowed; + + // Nothing in the names settled it, so a lone agreeing constant is the last + // evidence left — a branch pinning `kind` to what was supplied says more + // than one that merely permits any value there. + return agreeing.length === 1 ? agreeing[0] : null; } /** From 93bc7df360b7284803d27fc14d6a57b9f6b8bbdd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 09:33:21 -0400 Subject: [PATCH 039/213] fix(smoke): assert the requested engine is the one launched; fix the remedy Three findings, all from Copilot's suppressed block. 1. The tests never checked that loadBrowser launches the engine it was asked for. The failure-path stand-in rejects identically for every engine, so loadBrowser(root, "firefox") could have called chromium.launch() and still produced a correctly Firefox-labelled error. The smokes could not catch it either: the Firefox gate runs after Chromium is already installed, so a mis-dispatched launch would SUCCEED and report Firefox coverage that never happened. Added a success-path test with distinct per-engine spies asserting the requested engine is invoked, that nothing else is, and that its handle is what comes back. Mutation-verified: hard-coding playwright.chromium fails it. 2. The launch-failure message told the reader to run `npx playwright install --with-deps `, which is wrong now that the npm scripts run from the repo root rather than clients/web -- I introduced that when I removed the `cd`. Playwright is pinned in clients/web, so a bare root-level npx can fetch a different version and install a browser revision the pinned one still cannot launch. It now points at `npm run smoke:web:` first, and scopes the raw command to clients/web with the reason. That message named smoke:web:webkit, which did not exist -- only the chromium and firefox scripts did. Added it, and a test asserting every supported engine has one, since a remedy nobody can run is worse than no remedy. Mutation-verified. 3. AGENTS.md said npm run ci "mirrors" the workflow and then, two sentences later, that it is "a superset, not a mirror". It now leads with strict superset, which is the topology this PR is documenting. The README and AGENTS.md remediation wording is corrected the same way as the runtime message, so a reader gets the same advice wherever they find it. Addresses Copilot review 5030668906 on #2133. Signed-off-by: cliffhall --- AGENTS.md | 4 +-- README.md | 2 +- package.json | 1 + scripts/lib/headless-browser.mjs | 7 ++-- scripts/lib/headless-browser.test.mjs | 51 +++++++++++++++++++++++++-- scripts/run-engine-smokes.test.mjs | 15 ++++++++ 6 files changed, 73 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8f2b3f734..6af2fb791f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -914,7 +914,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) → `verify:bundle-externals` (the #2067 must-not-bundle gate) → `smoke` → **`smoke:web:firefox`** (the three browser-driven smokes again under Firefox — #2086; see below) → Storybook play-function tests (installs Playwright chromium if needed). Note `smoke:web:firefox` is the one step that is **not** in GitHub CI: it is a superset, not a mirror, and deliberately so. 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). +- **`npm run ci` is the mandatory pre-push command** — it is a **strict superset of `.github/workflows/main.yml`** (which additionally runs `npm install`, done separately here): `validate` → `coverage` → `verify:build-gate` (the #1769 browser-externalized-builtin build gate) → `verify:bundle-externals` (the #2067 must-not-bundle gate) → `smoke` → **`smoke:web:firefox`** (the three browser-driven smokes again under Firefox — #2086; see below) → Storybook play-function tests (installs Playwright chromium if needed). `smoke:web:firefox` is the step with no GitHub CI counterpart, deliberately (`smoke:tui` is the other, by self-skipping there). It also runs **`npm run coverage`**, the per-file ≥90 gate (lines/statements/functions/branches) that CI enforces — so the direction that matters holds: 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 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`. @@ -953,7 +953,7 @@ Everything about the selection lives in **`scripts/lib/headless-browser.mjs`** - **The engine matters for one surface, and it is the MCP Apps sandbox.** Most of the web client is React and Mantine, where a second engine buys little. The sandbox is built out of the primitives that genuinely diverge: a CSP `` injected as the first `` child of a **`srcdoc`** document, a **nested** sandboxed iframe, a `Permissions-Policy` `allow` attribute, and `postMessage` origin discipline across those two frames. - **No other tier can substitute, so don't propose one.** `sandbox-csp.test.ts` asserts which policy _string_ is built — environment-independent by construction, and it would pass identically on an engine that ignores `` CSP entirely. And **no Storybook story reaches the sandbox at all**: all three App stories (`AppRenderer`, `AppsScreen`, `AppElicitationHost`) point the iframe at a `data:` placeholder and hand the renderer a mock bridge, so `sandbox-csp.ts` is imported by exactly two things in the tree — its own test and `createAppBridgeFactory.ts`. Storybook stays Chromium-only; broadening it covers a much larger, differently-shaped surface and is a separate decision to be judged on its own cost. - **An unrecognized `SMOKE_BROWSER` is an error, never a fallback.** Falling back to Chromium would report a green Chromium run under a job labelled `webkit` — coverage claimed but not run, which is worse than none. -- **A launch failure names the engine that failed** and its `npx playwright install --with-deps `. Naming `chromium` while WebKit was the missing one sends the reader to install a browser they already have. +- **A launch failure names the engine that failed** and a remedy that works **from where the caller actually is**: `npm run smoke:web:`, or `npx playwright install --with-deps ` run *from `clients/web`*. Naming `chromium` while WebKit was missing would send the reader to install a browser they already have — and naming a bare root-level `npx playwright` would send them to a version that is not the pinned one, which can install a browser revision the pinned Playwright still cannot launch. - **Firefox is gated locally, not in CI, and that asymmetry is deliberate.** A GitHub Actions job was trialled and removed. It was cheap — ~2 minutes, parallel with the 15-minute `build` job, so zero added wall-clock — but across a dozen runs Firefox never once disagreed with Chromium, so it spent runner minutes on every push, from every branch, carrying a real flake surface (`playwright install --with-deps` runs `apt-get update`, which fails whenever a third-party repo in the runner image breaks) to re-confirm a result already in hand. Moving it into `npm run ci` keeps the check where a human is about to push a change they can still reason about, and pays for it once rather than on every push. **Don't re-add the CI job on the argument that cross-engine coverage is good in principle** — that argument was accepted, and the pre-push gate is what serves it. Re-add it on evidence: a cross-engine regression that reached `v2/main` because someone skipped the gate. - **This makes `npm run ci` a strict superset of GitHub CI rather than a mirror of it.** That was already the direction (`smoke:tui` self-skips on CI and runs only locally); Firefox is the second such step. The invariant that still holds, and the one that matters, is the useful direction: **passing `npm run ci` locally means CI's gates will pass.** - **`pack:verify` stays pinned to Chromium**, passing it explicitly rather than reading `SMOKE_BROWSER`. It is a _packaging_ check; the engine question belongs where the sandbox is under test, and pinning it also means it can't be pointed at an engine its npm script never installed. diff --git a/README.md b/README.md index d7004aec07..2632dc2d4f 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ SMOKE_BROWSER=webkit npm run smoke:web:app # one smoke, one engine SMOKE_BROWSER=firefox npm run smoke:web:engine # all three smokes, one engine ``` -Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run ci` — the mandatory pre-push gate — additionally runs all three smokes under Firefox** via `smoke:web:firefox`; **GitHub CI does not.** An unrecognized `SMOKE_BROWSER` is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for `webkit`. A missing browser binary fails naming the engine and its `npx playwright install --with-deps `. +Unset, the engine is `chromium`, so `npm run smoke` is unchanged. **`npm run ci` — the mandatory pre-push gate — additionally runs all three smokes under Firefox** via `smoke:web:firefox`; **GitHub CI does not.** An unrecognized `SMOKE_BROWSER` is an error, not a fallback: a silent fallback would report a green Chromium run for a command that asked for `webkit`. A missing browser binary fails naming the engine and a remedy that works from where you are — `npm run smoke:web:`, or `npx playwright install --with-deps ` run from `clients/web`, where Playwright is pinned. **Firefox passes all three smokes. WebKit fails the two App smokes**, for reasons nobody has identified. Two things are known: it does **not** reproduce in real Safari (an MCP App opens there normally), and an isolated repro of the mechanism it was first blamed on did not reproduce it under Playwright's WebKit either. So it reads as a property of that particular build rather than a bug users hit, and chasing it further was judged not worth the effort — treat a WebKit failure as unexplained rather than as a defect until someone has looked. diff --git a/package.json b/package.json index 827715967b..bc4ba1222b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "smoke:web:engine": "node scripts/run-engine-smokes.mjs", "smoke:web:chromium": "node scripts/run-engine-smokes.mjs chromium", "smoke:web:firefox": "node scripts/run-engine-smokes.mjs firefox", + "smoke:web:webkit": "node scripts/run-engine-smokes.mjs webkit", "smoke:launcher": "node scripts/smoke-launcher.mjs", "pack:verify": "node scripts/install-smoke-browser.mjs chromium && node scripts/pack-and-verify.mjs", "prepack": "npm run build", diff --git a/scripts/lib/headless-browser.mjs b/scripts/lib/headless-browser.mjs index d02de65e4c..0384566a89 100644 --- a/scripts/lib/headless-browser.mjs +++ b/scripts/lib/headless-browser.mjs @@ -187,8 +187,11 @@ export async function loadBrowser( return await playwright[browserName].launch({ headless: true }); } catch (err) { throw new Error( - `${browserName} failed to launch — run \`npx playwright install --with-deps ${browserName}\`, ` + - `which fetches the browser and (on a bare Linux box) its system libraries ` + + `${browserName} failed to launch — run \`npm run smoke:web:${browserName}\`, ` + + `or from clients/web run \`npx playwright install --with-deps ${browserName}\` ` + + `if you also need its system libraries. Playwright is pinned in ` + + `clients/web, so running that from the repo root can fetch a different ` + + `version and install a browser revision this one still cannot launch ` + `(${err instanceof Error ? err.message : String(err)})`, ); } diff --git a/scripts/lib/headless-browser.test.mjs b/scripts/lib/headless-browser.test.mjs index 02fcf2d49e..d3d1948b6a 100644 --- a/scripts/lib/headless-browser.test.mjs +++ b/scripts/lib/headless-browser.test.mjs @@ -16,7 +16,10 @@ * naming *that engine* and its own `playwright install` command, which is * exactly the kind of string that rots into naming the wrong one. * - * Only the successful launch is left to the smokes, since it needs a real binary. + * The successful path is covered here too, with distinct per-engine spies — the + * failure-path stand-in rejects identically for every engine, so it cannot tell + * a correct dispatch from a mis-dispatch that happens to print the right label. + * Only a launch against a REAL binary is left to the smokes. */ import assert from "node:assert/strict"; @@ -108,6 +111,44 @@ describe("loadBrowser", () => { assert.equal(loaded, false); }); + it("launches the engine that was asked for, and returns it", async () => { + // The load-bearing assertion, and the one the failure-path tests below + // CANNOT make (Copilot, #2133): their stand-in rejects identically for every + // engine, so `loadBrowser(root, "firefox")` could call `chromium.launch()` + // and still produce a correctly Firefox-labelled error. The smokes cannot + // catch that either — the Firefox gate runs after Chromium is already + // installed, so a mis-dispatched launch would succeed and report Firefox + // coverage that never happened. Only distinct per-engine spies pin it. + const launched = []; + const spyPlaywright = () => + Object.fromEntries( + SUPPORTED_BROWSERS.map((name) => [ + name, + { + launch: async (options) => { + launched.push({ name, options }); + return { engine: name }; + }, + }, + ]), + ); + + for (const name of SUPPORTED_BROWSERS) { + launched.length = 0; + const browser = await loadBrowser("/repo", name, { + loadPlaywright: spyPlaywright, + }); + assert.deepEqual( + launched.map((l) => l.name), + [name], + `loadBrowser(…, "${name}") must launch ${name} and nothing else`, + ); + // The returned handle is that engine's, not some other engine's. + assert.deepEqual(browser, { engine: name }); + assert.deepEqual(launched[0].options, { headless: true }); + } + }); + it("names the engine that failed, and its own install command", async () => { // The whole point of #2086's acceptance criterion: a reader whose WebKit // binary is missing must not be sent to install chromium. @@ -119,10 +160,16 @@ describe("loadBrowser", () => { }), (err) => { assert.match(err.message, new RegExp(`^${name} failed to launch`)); + // Names a remedy that works from where the caller is: the npm + // script, and the raw command scoped to clients/web (Playwright is + // pinned there; a bare root-level `npx playwright` can fetch a + // different version and install a revision this cannot launch). + assert.match(err.message, new RegExp(`npm run smoke:web:${name}`)); + assert.match(err.message, /from clients\/web/); assert.match( err.message, new RegExp( - `npx playwright install --with-deps ${name}\\\`(?![\\s\\S]*--with-deps (?!${name}))`, + `--with-deps ${name}\\\`(?![\\s\\S]*--with-deps (?!${name}))`, ), ); // The underlying cause survives, so the reader can tell a missing diff --git a/scripts/run-engine-smokes.test.mjs b/scripts/run-engine-smokes.test.mjs index 47197d1786..969c943f17 100644 --- a/scripts/run-engine-smokes.test.mjs +++ b/scripts/run-engine-smokes.test.mjs @@ -16,6 +16,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, it } from "node:test"; import { ENGINE_SMOKES } from "./run-engine-smokes.mjs"; +import { SUPPORTED_BROWSERS } from "./lib/headless-browser.mjs"; const scriptDir = import.meta.dirname; const scripts = JSON.parse( @@ -75,6 +76,20 @@ describe("every engine tier consumes ENGINE_SMOKES", () => { assert.match(scripts.ci, /smoke:web:firefox/); }); + it("every supported engine has a `smoke:web:` script", () => { + // loadBrowser's launch-failure message tells the reader to run + // `npm run smoke:web:`. If a supported engine has no such script, + // that remedy is unrunnable — which is how the message shipped naming + // `smoke:web:webkit` before it existed (Copilot, #2133). + for (const name of SUPPORTED_BROWSERS) { + assert.match( + scripts[`smoke:web:${name}`] ?? "", + new RegExp(`run-engine-smokes\\.mjs ${name}$`), + `npm run smoke:web:${name} is promised by the launch-failure message`, + ); + } + }); + it("each gated tier names its engine explicitly rather than reading the env", () => { // An ambient SMOKE_BROWSER must not be able to redirect a gate: without the // literal engine, `SMOKE_BROWSER=firefox npm run ci` would run Firefox twice From f31589a8f9d09a61526565cb2bfd05c4f02b5e3c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 09:42:50 -0400 Subject: [PATCH 040/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2026=20=E2=80=94=20a=20false=20property=20schema=20declines?= =?UTF-8?q?=20the=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON Schema's boolean form is legal, but only `true` is harmless: it constrains nothing, while `false` admits no value at all — so a field declared with it can never be filled, and a required one makes the whole branch unsatisfiable. Offering it contradicted every other faithfulness check in the resolver, so such a branch is declined. Signed-off-by: cliffhall --- clients/web/src/test/core/rootUnion.test.ts | 19 ++++++++++++++++--- core/json/rootUnion.ts | 16 +++++++++------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index bc29fbfed6..1fddfb0ce9 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -764,9 +764,9 @@ describe("resolveRootUnion", () => { } }); - it("offers a member carrying a boolean property schema", () => { - // JSON Schema's boolean form is legal and answers every keyword lookup - // with `undefined`, so it renders through the JSON fallback harmlessly. + it("offers a member carrying a `true` property schema", () => { + // `true` constrains nothing and answers every keyword lookup with + // `undefined`, so it renders through the JSON fallback harmlessly. const { branches } = resolveRootUnion({ type: "object", anyOf: [ @@ -777,6 +777,19 @@ describe("resolveRootUnion", () => { expect(branches).toHaveLength(2); }); + it("declines a member carrying a `false` property schema", () => { + // `false` admits no value at all, so the field can never be filled — and + // a required one makes the branch unsatisfiable. + const { branches } = resolveRootUnion({ + type: "object", + anyOf: [ + { type: "object", properties: { nothing: false } }, + { type: "object", properties: { other: { type: "string" } } }, + ] as unknown[], + }); + expect(branches).toEqual([]); + }); + it("declines a member whose properties are not an object", () => { // Members arrive as `unknown`, so this is reachable and must not throw. expect( diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 25059afe70..06104742b0 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -180,15 +180,17 @@ function isOfferable(branch: RootUnionSchema): boolean { const properties = Object.values(propertiesOf(branch)); return ( properties.length > 0 && - // Every value has to be something a renderer can read. JSON Schema's - // boolean form is legal and harmless — `true`/`false` answer every keyword - // lookup with `undefined` — but a `null` or an array is not a schema at - // all, and the web form dereferences one on the way to choosing a widget. - // A branch carrying one is declined rather than handed on to crash a tool - // panel that would otherwise have rendered. + // Every value has to be something a renderer can read AND something a + // caller can satisfy. A `null` or an array is not a schema at all, and the + // web form dereferences one on the way to choosing a widget. JSON Schema's + // boolean form is legal, but only `true` is harmless — it constrains + // nothing and answers every keyword lookup with `undefined`, while `false` + // admits no value whatsoever, so a field declared with it can never be + // filled and a required one makes the whole branch unsatisfiable. Either + // way the branch is declined rather than offered as a callable shape. properties.every( (property) => - typeof property === "boolean" || + property === true || (typeof property === "object" && property !== null && !Array.isArray(property)), From c8c5442dcf737994c773fdfe9d5dd8375d9e64cc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 10:07:35 -0400 Subject: [PATCH 041/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2027=20=E2=80=94=20unconstrained=20null=20branches,=20typed?= =?UTF-8?q?=20discriminators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - anyOfAdmitsNull recognizes a branch that constrains nothing (`{}`, or annotations only): it admits every value, null among them, so a field pinned to `const: null` beside such a union stays submittable. `admitsNull` cannot say that on its own — an unconstrained schema tells it nothing either way and it declines rather than guesses — but here the question is narrower. - convertToolParameters sends a matched `const` as the schema's own typed value: `kind=2` selects a branch pinned to `const: 2` and was then sent `"2"`, which that same branch rejects. Only an exact match is substituted; anything else is the user's input and is left alone. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 30 +++++++++++++++++++ .../web/src/test/core/nullableUnion.test.ts | 9 ++++++ core/json/jsonUtils.ts | 10 ++++++- core/json/nullableUnion.ts | 27 +++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 6cd4c5f3d9..e71b1e30f0 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -215,6 +215,36 @@ describe("JSON Utils", () => { }); }); + it("sends a non-string discriminator as its typed constant (#2123)", () => { + const numericallyPinned: Tool = { + name: "numerically-pinned", + inputSchema: { + type: "object", + oneOf: [ + { + type: "object", + properties: { kind: { const: 1 }, a: { type: "string" } }, + required: ["kind"], + }, + { + type: "object", + properties: { kind: { const: 2 }, b: { type: "string" } }, + required: ["kind"], + }, + ], + }, + }; + // `kind=2` selects the second branch, which then rejects `"2"` — the + // schema's own typed value is what goes on the wire. + expect(convertToolParameters(numericallyPinned, { kind: "2" })).toEqual({ + kind: 2, + }); + // Text that matches no constant is the user's input and is left alone. + expect(convertToolParameters(numericallyPinned, { kind: "9" })).toEqual({ + kind: "9", + }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index d7090864eb..c1054c8357 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -759,6 +759,15 @@ describe("admitsNull", () => { ); }); + it("admits null when a sibling branch constrains nothing", () => { + // `{}` is the equivalent of `true` — it admits every value, null among + // them — so the pinned null stays reachable and its field submittable. + expect(admitsNull({ const: null, anyOf: [{}] })).toBe(true); + expect(admitsNull({ const: null, anyOf: [{ title: "anything" }] })).toBe( + true, + ); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index aa5030c621..147a618021 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -264,7 +264,15 @@ export function convertToolParameters( for (const [key, value] of Object.entries(params)) { const paramSchema = properties[key] as ParameterSchema | undefined; - if (paramSchema) { + // A `const` the supplied text names is sent as the schema's own typed + // value, not as the text: a branch pinned to `const: 2` is selected by + // `kind=2` and would otherwise be sent `"2"`, which that same branch + // rejects. Only an exact match is substituted — anything else is the + // user's input and is left alone. + const pinned = (paramSchema as { const?: unknown } | undefined)?.const; + if (pinned !== undefined && String(pinned) === value) { + result[key] = pinned as JsonValue; + } else if (paramSchema) { result[key] = convertParameterValue(value, paramSchema); } else { result[key] = value; diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index e935b77c19..3217b0f17c 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -427,6 +427,28 @@ export function admitsNull(schema: NullableUnionSchema): boolean { return anyOfAdmitsNull(schema); } +/** + * Keywords that annotate rather than constrain — a schema carrying only these + * (or nothing at all) is the equivalent of `true`. + */ +const ANNOTATION_ONLY_KEYWORDS = new Set([ + "title", + "description", + "examples", + "default", + "deprecated", + "readOnly", + "writeOnly", + "$comment", +]); + +/** Whether a schema states no assertion at all. */ +function constrainsNothing(schema: NullableUnionSchema): boolean { + return Object.keys(schema).every((keyword) => + ANNOTATION_ONLY_KEYWORDS.has(keyword), + ); +} + /** Whether some `anyOf` branch is one that admits `null`. */ function anyOfAdmitsNull(schema: NullableUnionSchema): boolean { const branches = schema.anyOf; @@ -440,6 +462,11 @@ function anyOfAdmitsNull(schema: NullableUnionSchema): boolean { // a composition-free schema, which `admitsNull` answers without reaching // back into this function. if (hasUnevaluatedComposition(branchSchema)) return false; + // A branch that constrains nothing admits every value, `null` among them. + // `admitsNull` cannot say so on its own — an unconstrained schema tells it + // nothing either way, and it declines rather than guesses — but here the + // question is only whether this alternative leaves null on the table. + if (constrainsNothing(branchSchema)) return true; // Asked through `admitsNull` rather than by testing `type` alone, so a // branch spelling its nullability another way — `{ const: null }`, // `{ nullable: true }` — is recognized the same way the wrapper's own From 6ac45d9b26956dfcbae7c74905ef0de6bee4914b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 10:23:35 -0400 Subject: [PATCH 042/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2028=20=E2=80=94=20the=20boolean=20schema=20form=20in=20a=20?= =?UTF-8?q?null-admitting=20union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `anyOfAdmitsNull` sent every non-object member to `toBranch` and refused it, so `{ const: null, anyOf: [true] }` read as non-nullable — a required field seeded and rendered read-only as `null` and reported missing forever. `true` is the unconstrained schema and admits every value, `null` among them; `false` admits none and still does not. Signed-off-by: cliffhall --- clients/web/src/test/core/nullableUnion.test.ts | 6 ++++++ core/json/nullableUnion.ts | 3 +++ 2 files changed, 9 insertions(+) diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index c1054c8357..24592e242f 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -768,6 +768,12 @@ describe("admitsNull", () => { ); }); + it("reads the boolean schema form", () => { + // `true` is the unconstrained schema; `false` admits nothing at all. + expect(admitsNull({ const: null, anyOf: [true] })).toBe(true); + expect(admitsNull({ const: null, anyOf: [false] })).toBe(false); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 3217b0f17c..fd6761dccd 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -454,6 +454,9 @@ function anyOfAdmitsNull(schema: NullableUnionSchema): boolean { const branches = schema.anyOf; if (!Array.isArray(branches)) return false; return branches.some((entry) => { + // JSON Schema's boolean form: `true` is the unconstrained schema and admits + // every value, `null` among them, while `false` admits none. + if (typeof entry === "boolean") return entry; const branch = toBranch(entry); if (branch === null) return false; const branchSchema = branch as NullableUnionSchema; From 6aa25da58570b966ab2da6af73f504f7212d2fbc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 10:45:16 -0400 Subject: [PATCH 043/213] docs: distinguish the partial render from the empty one in the showcase The README claimed both showcase tools rendered nothing but the Execute Tool button, which the PR's own screenshots contradict: `echo` carries a root `message` and rendered that one field, so it was callable with half its arguments; only `get_weather`, whose fields live entirely on its `oneOf`, rendered no controls at all. The two now show the two halves deliberately. Signed-off-by: cliffhall --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f28891f5b..139272c3da 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,9 @@ The **TUI** had the same gap and is worth checking against the same server (`--t The 2026-07-28 revision makes this shape explicitly legal: `type: "object"` is required at the root, and beyond that "any JSON Schema 2020-12 keyword may appear alongside `type`, including composition keywords (`oneOf`, `anyOf`, `allOf`, `not`)". -Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. On the broken build both tools rendered **nothing but the Execute Tool button**: no picker, no fields, not even the raw-JSON editor a union-typed _property_ falls back to, so neither tool could be called with anything but empty arguments ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)). +Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. + +The two tools show the two halves of the old behavior. On the broken build `echo` rendered its root `message` and **nothing from either branch**, so it could only ever be called with half its arguments; `get_weather`, whose fields live entirely on its `oneOf`, rendered **nothing but the Execute Tool button** — no picker, no fields, not even the raw-JSON editor a union-typed _property_ falls back to ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)). Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making. From 86c143fd0de4590ab6c450fae9f3e07807c94345 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 11:00:51 -0400 Subject: [PATCH 044/213] docs(smoke): point each smoke's on-demand example at its own command smoke-web-elicitation.mjs told the reader to run `SMOKE_BROWSER=webkit npm run smoke:web:app` -- the App smoke, not the elicitation smoke the file documents. Following it exercises a different path entirely, and it does not fail while doing so: a real smoke runs and passes, so nothing signals that you never tested what you were reading about. This is a direct cost of my own decision two rounds ago to make the two App headers word-for-word identical so they could not drift. The shared PROSE should be identical; the example command is the one line that must not be, and copy-paste does not distinguish them. Both headers now name their own smoke, and both point at npm run smoke:web:webkit for all three. Guarded, since the class is invisible at runtime by construction: run-engine-smokes.test.mjs now asserts no smoke header tells the reader to run a sibling's command, and that every `npm run` it does name is a real script. Both mutation-verified -- restoring the exact bug fails the first, and naming a nonexistent script fails the second. Addresses the suppressed comment on Copilot review 5031065042 of #2133. Signed-off-by: cliffhall --- scripts/run-engine-smokes.test.mjs | 47 ++++++++++++++++++++++++++++++ scripts/smoke-web-app.mjs | 3 +- scripts/smoke-web-elicitation.mjs | 3 +- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/scripts/run-engine-smokes.test.mjs b/scripts/run-engine-smokes.test.mjs index 969c943f17..b0cebe5876 100644 --- a/scripts/run-engine-smokes.test.mjs +++ b/scripts/run-engine-smokes.test.mjs @@ -106,3 +106,50 @@ describe("every engine tier consumes ENGINE_SMOKES", () => { assert.match(scripts["smoke:web:engine"], /run-engine-smokes\.mjs$/); }); }); + +describe("each smoke's own docs point at its own command", () => { + /** The npm script that runs each engine-sensitive smoke on its own. */ + const OWN_SCRIPT = { + "smoke-web-browser.mjs": "smoke:web:browser", + "smoke-web-app.mjs": "smoke:web:app", + "smoke-web-elicitation.mjs": "smoke:web:elicit", + }; + + it("never tells the reader to run a sibling smoke", () => { + // `smoke-web-elicitation.mjs` shipped an on-demand example invoking + // `smoke:web:app` (Copilot, #2133) — a direct cost of my deciding the two + // App headers should be word-for-word identical so they could not drift. + // The shared PROSE should be identical; the example command is the one line + // that must not be, and copy-paste does not distinguish them. + // + // Following such an example does not fail — it runs a real smoke and passes. + // It just never exercises the file you were reading about, which is why + // nothing else catches this. + for (const [file, own] of Object.entries(OWN_SCRIPT)) { + const source = readFileSync(join(scriptDir, file), "utf8"); + const siblings = Object.values(OWN_SCRIPT).filter((s) => s !== own); + for (const sibling of siblings) { + assert.ok( + !source.includes(`npm run ${sibling}`), + `${file} tells the reader to run \`npm run ${sibling}\` — a sibling ` + + `smoke. Its examples must use \`${own}\`, or the reader never ` + + `exercises the path this file documents.`, + ); + } + } + }); + + it("every command it does name is a real script", () => { + // A command that does not exist is worse than none, and the header is the + // one place nothing executes to find out. + for (const file of Object.keys(OWN_SCRIPT)) { + const source = readFileSync(join(scriptDir, file), "utf8"); + for (const [, name] of source.matchAll(/npm run ([\w:]+)/g)) { + assert.ok( + Object.hasOwn(scripts, name), + `${file} names \`npm run ${name}\`, which is not a script`, + ); + } + } + }); +}); diff --git a/scripts/smoke-web-app.mjs b/scripts/smoke-web-app.mjs index cd1e932330..a6b39060a4 100644 --- a/scripts/smoke-web-app.mjs +++ b/scripts/smoke-web-app.mjs @@ -50,7 +50,8 @@ * - **`npm run ci`**, the local pre-push gate, runs it in **Chromium and * Firefox** — the Firefox pass is `smoke:web:firefox`, and it is the one * gate step with no GitHub CI counterpart. - * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:app`. + * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:app` + * for this smoke alone, or `npm run smoke:web:webkit` for all three. * * Firefox passes. WebKit fails this smoke for reasons nobody has identified and * nobody is investigating: it does not reproduce in real Safari, and an isolated diff --git a/scripts/smoke-web-elicitation.mjs b/scripts/smoke-web-elicitation.mjs index 364ea7308f..ab18f33a49 100644 --- a/scripts/smoke-web-elicitation.mjs +++ b/scripts/smoke-web-elicitation.mjs @@ -31,7 +31,8 @@ * - **`npm run ci`**, the local pre-push gate, runs it in **Chromium and * Firefox** — the Firefox pass is `smoke:web:firefox`, and it is the one * gate step with no GitHub CI counterpart. - * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:app`. + * - **WebKit is on demand only**: `SMOKE_BROWSER=webkit npm run smoke:web:elicit` + * for this smoke alone, or `npm run smoke:web:webkit` for all three. * * Firefox passes. WebKit fails this smoke for reasons nobody has identified and * nobody is investigating: it does not reproduce in real Safari, and an isolated From 67e7a4ca1a3aaf9529711db6452cef449dff05b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 12:01:08 -0400 Subject: [PATCH 045/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2030=20=E2=80=94=20seed=20a=20read-only=20const=20for=20call?= =?UTF-8?q?ers=20that=20do=20not?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rendering every `const` read-only assumed the caller had already seeded it, which SchemaForm never did itself: `InlineElicitationRequest` passes its values straight through and gates submit on hasMissingRequiredFields, so a required `const` — a root union's discriminator among them — was displayed and never supplied, leaving Submit disabled with no way to fix it. The form now reports the fixed values upward once per entity, in an effect (the parent's `onChange` cannot be called during our render), adding only names absent from `values` — so a caller that already seeds sees no call at all and the report cannot loop. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 27 ++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 35 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index a0ed9b2feb..575c52d040 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -7,6 +7,7 @@ import { fireEvent, renderWithMantine, screen, + waitFor, } from "../../../test/renderWithMantine"; import { SchemaForm } from "./SchemaForm"; @@ -2264,6 +2265,32 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(screen.getByRole("textbox", { name: /Alpha/ })).toBeTruthy(); }); + it("reports the branch's fixed values upward when mounted with none", async () => { + // A read-only `const` the caller never seeded would otherwise be + // displayed and, if required, keep submit disabled forever. + const onChange = vi.fn(); + renderWithMantine( + , + ); + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ kind: "email" }), + ); + }); + + it("reports nothing when the caller already seeded them", async () => { + const onChange = vi.fn(); + renderWithMantine( + , + ); + // Nothing is missing, so the form does not touch the caller's values. + await Promise.resolve(); + expect(onChange).not.toHaveBeenCalled(); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 155f5205ba..8dfde16f0c 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -706,6 +706,41 @@ export function SchemaForm({ [], ); + // Report the schema's own fixed values upward once per entity, for a caller + // that mounts the form with nothing seeded. + // + // A `const` field is rendered read-only, so the user cannot supply it — and a + // required one then leaves submit disabled forever, on a value that was never + // in doubt. Most callers seed through `collectSchemaDefaults` before mounting; + // this covers the ones that do not, rather than making every caller + // responsible for a value the form is already displaying. + // + // An effect, not a render-time update: `onChange` belongs to the parent, and + // calling it during our render would update another component mid-render. + // Only names absent from `values` are added, so a caller that has already + // seeded them sees no call at all, and re-running cannot loop. + const latestSeed = useRef({ schema: effectiveSchema, values, onChange }); + // Written in an effect, not during render — the same shape the validity + // reporter below uses, and what `react-hooks/refs` requires. + useEffect(() => { + latestSeed.current = { schema: effectiveSchema, values, onChange }; + }); + useEffect(() => { + const { + schema: current, + values: held, + onChange: report, + } = latestSeed.current; + const missing = Object.entries(collectSchemaDefaults(current)).filter( + ([name]) => !Object.hasOwn(held, name), + ); + if (missing.length > 0) { + report({ ...held, ...Object.fromEntries(missing) }); + } + // Keyed by the entity and branch being edited: a different one has + // different fixed values, and the same one needs this only once. + }, [draftKey]); + // 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 From c9162d0e73d0da554624c70f1ed3aa9c56354cf5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 12:25:46 -0400 Subject: [PATCH 046/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2031=20=E2=80=94=20proto-safe=20results,=20set-valued=20type?= =?UTF-8?q?s,=20core=20keywords?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - convertToolParameters defines its result keys rather than assigning them, so a discriminator legally named `__proto__` reaches the call instead of the prototype setter. It also collapses a nullable declaration before dispatching, so `type: ["number","null"]` coerces as a number rather than being sent as text. - typeNameOf sorts an array `type`: JSON Schema reads it as a SET, so `["number","null"]` and `["null","number"]` are one declaration and must not read as branches disagreeing. - nullableUnion's non-asserting keyword set covers `$defs`, `$id`, `$schema`, `$anchor`, `$dynamicAnchor` and `$vocabulary` — they identify or declare rather than assert. `$ref` stays out: it applies whatever it points at. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 41 ++++++++++++++++++- .../web/src/test/core/nullableUnion.test.ts | 10 +++++ core/json/jsonUtils.ts | 38 +++++++++++++---- core/json/nullableUnion.ts | 9 ++++ 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index e71b1e30f0..6c46a7e6ac 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -245,6 +245,42 @@ describe("JSON Utils", () => { }); }); + it("keeps a converted argument named __proto__ (#2123)", () => { + const protoNamed: Tool = { + name: "proto-named", + inputSchema: { + type: "object", + properties: Object.fromEntries([["__proto__", { type: "number" }]]), + }, + }; + const converted = convertToolParameters(protoNamed, { + ["__proto__"]: "3", + }); + expect(Object.hasOwn(converted, "__proto__")).toBe(true); + }); + + it("reads an array type as a set when branches agree (#2123)", () => { + const setTyped: Tool = { + name: "set-typed", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { v: { type: ["number", "null"] }, a: {} }, + }, + { + type: "object", + properties: { v: { type: ["null", "number"] }, b: {} }, + }, + ], + }, + }; + // The same declaration written in the other order — not a disagreement, + // so the coercion survives. + expect(convertToolParameters(setTyped, { v: "2" })).toEqual({ v: 2 }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", @@ -293,8 +329,9 @@ describe("JSON Utils", () => { ], }, }; - // Both spell the type the same way, so it is not ambiguous. - expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: "2" }); + // Both spell the type the same way, so it is not ambiguous — and the + // nullable declaration collapses to `number`, which is what coerces. + expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: 2 }); }); it("ignores a malformed branch declaration when matching constants (#2123)", () => { diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 24592e242f..48b4a9bb6f 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -774,6 +774,16 @@ describe("admitsNull", () => { expect(admitsNull({ const: null, anyOf: [false] })).toBe(false); }); + it("treats core identifier keywords as asserting nothing", () => { + // `$defs` declares subschemas for reference; it constrains nothing on its + // own, so this branch is unconstrained and admits null. + expect(admitsNull({ const: null, anyOf: [{ $defs: {} }] })).toBe(true); + // `$ref` is different — it applies whatever it points at. + expect(admitsNull({ const: null, anyOf: [{ $ref: "#/$defs/T" }] })).toBe( + false, + ); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 147a618021..040917aa09 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -1,4 +1,5 @@ import type { Tool } from "@modelcontextprotocol/client"; +import { normalizeNullableUnion } from "./nullableUnion.js"; import { narrowBySuppliedNames, resolveRootUnion, @@ -220,8 +221,11 @@ function coercionProperties( function typeNameOf(schema: unknown): string { if (typeof schema !== "object" || schema === null) return ""; const { type } = schema as { type?: unknown }; + // Sorted: JSON Schema reads an array `type` as a SET, so `["number","null"]` + // and `["null","number"]` are the same declaration and must not read as a + // disagreement that drops the property from the coercion map. return Array.isArray(type) - ? type.join(",") + ? [...type].map(String).sort().join(",") : typeof type === "string" ? type : ""; @@ -262,7 +266,15 @@ export function convertToolParameters( const { base, branches } = resolveRootUnion(tool.inputSchema ?? {}); const properties = coercionProperties(base, branches, params); for (const [key, value] of Object.entries(params)) { - const paramSchema = properties[key] as ParameterSchema | undefined; + const declared = properties[key]; + // Collapsed first: a nullable declaration (`type: ["number","null"]`, or an + // `anyOf` with a null branch) states its real type on the surviving branch, + // and `convertParameterValue` dispatches on a single `type` string — so + // without this a nullable number is sent as the string it was typed as. + const paramSchema = + typeof declared === "object" && declared !== null + ? (normalizeNullableUnion(declared) as ParameterSchema) + : (declared as ParameterSchema | undefined); // A `const` the supplied text names is sent as the schema's own typed // value, not as the text: a branch pinned to `const: 2` is selected by @@ -270,13 +282,21 @@ export function convertToolParameters( // rejects. Only an exact match is substituted — anything else is the // user's input and is left alone. const pinned = (paramSchema as { const?: unknown } | undefined)?.const; - if (pinned !== undefined && String(pinned) === value) { - result[key] = pinned as JsonValue; - } else if (paramSchema) { - result[key] = convertParameterValue(value, paramSchema); - } else { - result[key] = value; - } + const converted = + pinned !== undefined && String(pinned) === value + ? (pinned as JsonValue) + : paramSchema + ? convertParameterValue(value, paramSchema) + : value; + // `defineProperty`, not assignment: `__proto__` is a legal argument name — + // a discriminator can carry it — and assigning it would invoke the legacy + // prototype setter instead of putting it in the call. + Object.defineProperty(result, key, { + value: converted, + writable: true, + enumerable: true, + configurable: true, + }); } return result; diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index fd6761dccd..9232abddce 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -440,6 +440,15 @@ const ANNOTATION_ONLY_KEYWORDS = new Set([ "readOnly", "writeOnly", "$comment", + // Core keywords that identify or declare rather than assert. `$ref` is + // deliberately absent: it applies whatever it points at, which is exactly an + // assertion this module cannot see. + "$defs", + "$id", + "$schema", + "$anchor", + "$dynamicAnchor", + "$vocabulary", ]); /** Whether a schema states no assertion at all. */ From 00d9905781873d5ff67c2b675d5ef88e1ac2ad6c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 12:50:20 -0400 Subject: [PATCH 047/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2032=20=E2=80=94=20normalize=20before=20the=20type=20vote,?= =?UTF-8?q?=20enum-spelled=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coercionProperties collapses each declaration before comparing types. A nullable schema written as an `anyOf` states no top-level `type`, so `number | null` and `boolean | null` both read as "no type" and counted as agreeing — and `value=true` then came back as `NaN` through the first branch's number. - admitsNull reads an `enum` offering null, under the same sibling conditions a `const: null` answers to. Without it `{ enum: [null] }` reported that it rejects null, and a required read-only field spelled that way stayed permanently invalid. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 31 +++++++++++++++++++ .../web/src/test/core/nullableUnion.test.ts | 7 +++++ core/json/jsonUtils.ts | 7 ++++- core/json/nullableUnion.ts | 10 ++++++ 4 files changed, 54 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 6c46a7e6ac..017a5621b5 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -281,6 +281,37 @@ describe("JSON Utils", () => { expect(convertToolParameters(setTyped, { v: "2" })).toEqual({ v: 2 }); }); + it("sees through nullable encodings when branches disagree (#2123)", () => { + const nullableDisagreement: Tool = { + name: "nullable-disagreement", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + value: { anyOf: [{ type: "number" }, { type: "null" }] }, + a: {}, + }, + }, + { + type: "object", + properties: { + value: { anyOf: [{ type: "boolean" }, { type: "null" }] }, + b: {}, + }, + }, + ], + }, + }; + // Neither declaration states a top-level `type`, so uncollapsed they + // would both read as "no type" and agree — and `value=true` would come + // back as `NaN` through the first branch's number. + expect( + convertToolParameters(nullableDisagreement, { value: "true" }), + ).toEqual({ value: "true" }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/clients/web/src/test/core/nullableUnion.test.ts b/clients/web/src/test/core/nullableUnion.test.ts index 48b4a9bb6f..75589d9b54 100644 --- a/clients/web/src/test/core/nullableUnion.test.ts +++ b/clients/web/src/test/core/nullableUnion.test.ts @@ -784,6 +784,13 @@ describe("admitsNull", () => { ); }); + it("reads an enum that offers null", () => { + expect(admitsNull({ enum: [null] })).toBe(true); + expect(admitsNull({ const: null, anyOf: [{ enum: [null] }] })).toBe(true); + // …under the same sibling conditions a `const: null` answers to. + expect(admitsNull({ type: "string", enum: [null] })).toBe(false); + }); + it("does not override a sibling that rejects null", () => { // `const` is conjunctive with its siblings, not an override: both of // these reject every value, so claiming nullability would let the diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 040917aa09..0040291547 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -201,7 +201,12 @@ function coercionProperties( // A malformed declaration (`properties: { x: null }`) is not a vote about // the type, and storing it as the coercion schema would put a value that // is not a schema where one is expected. - .filter((schema) => typeof schema === "object" && schema !== null); + .filter((schema) => typeof schema === "object" && schema !== null) + // Collapsed BEFORE the vote: a nullable declaration states its real type + // on the surviving branch, so `number | null` and `boolean | null` would + // otherwise both read as "no type" and be counted as agreeing — and the + // first would then coerce `value=true` to `NaN`. + .map((schema) => normalizeNullableUnion(schema as object)); const types = new Set(declarations.map((schema) => typeNameOf(schema))); if (types.size === 1 && declarations.length > 0) { Object.defineProperty(properties, name, { diff --git a/core/json/nullableUnion.ts b/core/json/nullableUnion.ts index 9232abddce..2a5e73a603 100644 --- a/core/json/nullableUnion.ts +++ b/core/json/nullableUnion.ts @@ -406,6 +406,16 @@ export function admitsNull(schema: NullableUnionSchema): boolean { return schema.anyOf === undefined || anyOfAdmitsNull(schema); } + // An `enum` offering `null` admits it, the same way a `const: null` does — + // and under the same sibling conditions, since the two are equally + // conjunctive with whatever else the schema states. + if (Array.isArray(schema.enum) && schema.enum.includes(null)) { + return ( + (schema.nullable === true || typeAdmitsNull(schema.type)) && + (schema.anyOf === undefined || anyOfAdmitsNull(schema)) + ); + } + if (schema.nullable === true) { return true; } From ffa08d76f54cece5b75841f3689fd3ec3859bee2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 13:09:54 -0400 Subject: [PATCH 048/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2033=20=E2=80=94=20keep=20a=20cleared=20field=20cleared=20ac?= =?UTF-8?q?ross=20a=20branch=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carry filter dropped a name whose value was `undefined`, but clearing a number or JSON field leaves exactly that: the name present with no value, which is the user's answer rather than an absence. The incoming branch's defaults then put the field's default back and undid the clear. Own-property presence alone is now the test. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 25 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 5 +++- 2 files changed, 29 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 575c52d040..0d4927140a 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2291,6 +2291,31 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(onChange).not.toHaveBeenCalled(); }); + it("keeps a cleared root field cleared across a branch switch", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const schema: InspectorFormSchema = { + type: "object", + properties: { count: { type: "number", default: 7, title: "Count" } }, + anyOf: [ + { type: "object", title: "A", properties: { x: {} } }, + { type: "object", title: "B", properties: { y: {} } }, + ], + }; + renderWithMantine( + , + ); + await user.click(screen.getByRole("textbox", { name: /Variant/ })); + await user.click(screen.getByRole("option", { name: "B" })); + expect(onChange).toHaveBeenCalledWith({ count: undefined }); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 8dfde16f0c..7cd2b17486 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -799,8 +799,11 @@ export function SchemaForm({ Object.entries(nextProperties) .filter( ([name, fieldSchema]) => + // Own-property presence alone, `undefined` included: clearing a + // number or JSON field leaves the name present with no value, and + // that is the user's answer. Dropping it would let the defaults + // below put the field's default back and undo the clear. Object.hasOwn(values, name) && - values[name] !== undefined && fieldSchema.const === undefined && // Carried only where the incoming branch leaves the root's // declaration as it found it. Anything the incoming branch declares From ba91db97e241d5afa0b00ae5453543e11e1d2295 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 13:39:16 -0400 Subject: [PATCH 049/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2034=20=E2=80=94=20structured=20constants,=20non-required=20?= =?UTF-8?q?const=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A `const` may be an object or an array, with or without a `type`, and `String({...})` is "[object Object]" — which no CLI argument can equal, so the only value such a schema accepts never matched. Supplied text is parsed and compared structurally through the resolver's own comparison (exported as `sameJsonValue` so the two cannot disagree), for both branch selection and the substitution. - The TUI's one-option const control is never marked required: its option may legitimately be the empty string, which ink-form's required gate can never accept, so submission would not even reach `decodeFormValues`. The value is fixed by the schema, and `missingRequiredFields` still validates the call. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 18 ++++++++++++++++ clients/tui/src/utils/schemaToForm.ts | 6 ++++++ clients/web/src/test/core/jsonUtils.test.ts | 22 +++++++++++++++++++ core/json/jsonUtils.ts | 24 +++++++++++++++++++-- core/json/rootUnion.ts | 12 ++++++++++- 5 files changed, 79 insertions(+), 3 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index ec8d3aeb6c..1ced53578d 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -503,6 +503,24 @@ describe("schemaToForm", () => { }); }); + it("never marks a const control required", () => { + // Its one option may be the empty string, which ink-form's required gate + // can never accept — the call would not even reach `decodeFormValues`. + const form = schemaToForm( + { + type: "object", + properties: { kind: { type: "string", const: "" } }, + required: ["kind"], + }, + "empty_const", + ); + expect(form.sections[0]!.fields[0]).toMatchObject({ + name: "kind", + type: "select", + required: false, + }); + }); + it("renders a const outside a union the same way", () => { const form = schemaToForm( { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index dd034cf68a..4eb4fc9f33 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -357,6 +357,12 @@ function buildFields(schema: JsonSchemaObject): FormField[] { fields.push({ type: "select", ...baseField, + // Never required: the one option may legitimately be the empty string, + // which ink-form's required gate can never accept — submission would + // not even reach `decodeFormValues`, which reapplies the constant. The + // value is fixed by the schema, and `missingRequiredFields` still + // validates the decoded call. + required: false, initialValue: String(pinned), options: [{ label: String(pinned), value: String(pinned) }], } as FormField); diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 017a5621b5..68c4190b49 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -312,6 +312,28 @@ describe("JSON Utils", () => { ).toEqual({ value: "true" }); }); + it("matches a structured const by parsing the supplied text (#2123)", () => { + const structured: Tool = { + name: "structured-const", + inputSchema: { + type: "object", + properties: { tag: { const: { kind: "x", n: 1 } } }, + }, + }; + // `String({...})` is "[object Object]", which no argument can equal — so + // the only value the schema accepts would never have matched. + expect( + convertToolParameters(structured, { tag: '{"n":1,"kind":"x"}' }), + ).toEqual({ tag: { kind: "x", n: 1 } }); + // Text that is not that value, or not JSON at all, is left alone. + expect(convertToolParameters(structured, { tag: "{}" })).toEqual({ + tag: "{}", + }); + expect(convertToolParameters(structured, { tag: "nope" })).toEqual({ + tag: "nope", + }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index 0040291547..cecce758fe 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -2,6 +2,7 @@ import type { Tool } from "@modelcontextprotocol/client"; import { normalizeNullableUnion } from "./nullableUnion.js"; import { narrowBySuppliedNames, + sameJsonValue, resolveRootUnion, type RootUnionBranch, type RootUnionSchema, @@ -253,10 +254,29 @@ function matchesConstants( // read the inherited one and rule out every branch that pins that name. if (!Object.hasOwn(params, name)) return true; const supplied = params[name]; - return supplied === undefined || supplied === String(constValue); + return supplied === undefined || suppliedMatchesConst(supplied, constValue); }); } +/** + * Whether the text a CLI argument carries is the value a `const` fixes. + * + * A primitive constant is compared as text, which is all a command line has. A + * structured one — a `const` may be an object or an array, with or without a + * `type` — is parsed first: `String({...})` is `"[object Object]"`, which no + * argument can equal, so the only value the schema accepts would never match. + */ +function suppliedMatchesConst(value: string, constValue: unknown): boolean { + if (constValue === null || typeof constValue !== "object") { + return value === String(constValue); + } + try { + return sameJsonValue(JSON.parse(value), constValue); + } catch { + return false; + } +} + /** * Convert string parameters to JSON values based on tool schema */ @@ -288,7 +308,7 @@ export function convertToolParameters( // user's input and is left alone. const pinned = (paramSchema as { const?: unknown } | undefined)?.const; const converted = - pinned !== undefined && String(pinned) === value + pinned !== undefined && suppliedMatchesConst(value, pinned) ? (pinned as JsonValue) : paramSchema ? convertParameterValue(value, paramSchema) diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 06104742b0..01db82907c 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -240,7 +240,17 @@ function canonicalJson(value: unknown): string { return JSON.stringify(value) ?? "undefined"; } -/** Structural equality, via canonical JSON — enough for schema keyword values. */ +/** + * Structural equality, via canonical JSON — enough for schema keyword values. + * + * Exported because a caller comparing a *supplied* value against a schema's + * `const` has to reach the same answer this module does; two implementations + * would disagree the moment one of them met an object. + */ +export function sameJsonValue(a: unknown, b: unknown): boolean { + return sameValue(a, b); +} + function sameValue(a: unknown, b: unknown): boolean { return a === b || canonicalJson(a) === canonicalJson(b); } From 139563f7203594e6e1b9abc32ff9a61a98962030 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 14:02:22 -0400 Subject: [PATCH 050/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2035=20=E2=80=94=20reseed=20on=20an=20in-place=20schema=20ch?= =?UTF-8?q?ange,=20label=20every=20primitive=20const?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The seeding effect is keyed by the fixed values themselves as well as the entity and branch. A tool refreshed in place keeps its `resetKey` and its branch while its schema changes underneath, so a newly pinned field was rendered read-only and never seeded — leaving a required one permanently unsubmittable. The schema object's identity says nothing (callers rebuild it every render), which is why the key is derived from what has to be seeded. - branchLabel stringifies every primitive constant. `hasDiscriminator` accepts distinct booleans and nulls, so a `true`/`false` union was discriminated perfectly well and still labelled "Option 1"/"Option 2". Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 39 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 13 +++++-- clients/web/src/test/core/rootUnion.test.ts | 14 +++++++ core/json/rootUnion.ts | 19 ++++++--- 4 files changed, 76 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 0d4927140a..8e42c4522b 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2316,6 +2316,45 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(onChange).toHaveBeenCalledWith({ count: undefined }); }); + it("seeds a newly pinned field when the schema changes in place", async () => { + // A tool refreshed in place keeps its `resetKey` and its branch while the + // schema changes underneath — the new read-only field must still be + // seeded, or a required one leaves submit disabled with no way to fix it. + const onChange = vi.fn(); + const before: InspectorFormSchema = { + type: "object", + properties: { a: { type: "string", title: "A" } }, + }; + const { rerender } = renderWithMantine( + , + ); + await Promise.resolve(); + onChange.mockClear(); + + rerender( + , + ); + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ version: "2" }), + ); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 7cd2b17486..047502bb43 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -719,6 +719,11 @@ export function SchemaForm({ // calling it during our render would update another component mid-render. // Only names absent from `values` are added, so a caller that has already // seeded them sees no call at all, and re-running cannot loop. + // The fixed values themselves, as a stable key: callers rebuild the schema + // object every render, so its identity says nothing, while this changes + // exactly when what has to be seeded changes. + const seedKey = serializeJson(collectSchemaDefaults(effectiveSchema)); + const latestSeed = useRef({ schema: effectiveSchema, values, onChange }); // Written in an effect, not during render — the same shape the validity // reporter below uses, and what `react-hooks/refs` requires. @@ -737,9 +742,11 @@ export function SchemaForm({ if (missing.length > 0) { report({ ...held, ...Object.fromEntries(missing) }); } - // Keyed by the entity and branch being edited: a different one has - // different fixed values, and the same one needs this only once. - }, [draftKey]); + // Keyed by the entity and branch being edited, and by the fixed values + // themselves: a tool refreshed in place keeps its `resetKey` and its branch + // while its schema changes underneath, and a newly pinned field would + // otherwise be rendered read-only and never seeded. + }, [draftKey, seedKey]); // 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 diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 1fddfb0ce9..6910056121 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -583,6 +583,20 @@ describe("resolveRootUnion", () => { expect(branches[0].label).toBe("email"); }); + it("labels boolean and null constants by their values", () => { + // `hasDiscriminator` accepts these, so the label must too — otherwise a + // perfectly discriminated union reads as "Option 1"/"Option 2". + const { branches } = resolveRootUnion({ + type: "object", + required: ["v"], + oneOf: [ + { type: "object", properties: { v: { const: true }, a: {} } }, + { type: "object", properties: { v: { const: null }, b: {} } }, + ], + }); + expect(branches.map((branch) => branch.label)).toEqual(["true", "null"]); + }); + it("labels a numeric const by its value", () => { const { branches } = resolveRootUnion({ type: "object", diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 01db82907c..b480f4e821 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -586,20 +586,27 @@ function branchLabel( return branch.title; } const properties = propertiesOf(branch); - const constOf = (name: string): string | null => { + // Every primitive constant is a usable label, not just the string and number + // cases: `hasDiscriminator` accepts distinct booleans and nulls, so a + // `true`/`false` union would otherwise be labelled "Option 1"/"Option 2" + // while being discriminated perfectly well. An object or array constant is + // not a label — `undefined` marks "nothing usable here", which lets a `null` + // constant be a value rather than the sentinel. + const constOf = (name: string): string | undefined => { const property = toBranch(properties[name]) as { const?: unknown } | null; - const value = property?.const; - return typeof value === "string" || typeof value === "number" + if (property === null || !("const" in property)) return undefined; + const value = property.const; + return value === null || typeof value !== "object" ? String(value) - : null; + : undefined; }; if (discriminatorProperty !== undefined) { const value = constOf(discriminatorProperty); - if (value !== null) return value; + if (value !== undefined) return value; } const constants = Object.keys(properties) .map((name) => constOf(name)) - .filter((value): value is string => value !== null); + .filter((value): value is string => value !== undefined); if (constants.length === 1) return constants[0]; return `Option ${index + 1}`; } From f78b541ce101723f9819731e3516fb8f3ee023d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 14:25:07 -0400 Subject: [PATCH 051/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2036=20=E2=80=94=20re-derive=20the=20branch=20when=20the=20u?= =?UTF-8?q?nion=20changes=20underneath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `branchIndex` is a position, and a tool refreshed in place keeps its `resetKey` — so a union reordered or rewritten with the same number of branches left the index pointing at a different shape than the one the values describe: the picker showed SMS while Execute submitted the email arguments. The selection is now re-derived from the values whenever the alternatives themselves change, keyed by their labels and declared fields. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 36 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 12 +++++++ 2 files changed, 48 insertions(+) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 8e42c4522b..9aa55d23c5 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2355,6 +2355,42 @@ describe("SchemaForm multiline strings (#2042)", () => { ); }); + it("re-derives the selection when the branches are reordered in place", () => { + // Same `resetKey`, same branch count — only the order changed, so a + // numeric index would now point at the other shape and the picker would + // show SMS while `values` describe email. + const reversed: InspectorFormSchema = { + ...UNION_SCHEMA, + anyOf: [...(UNION_SCHEMA.anyOf ?? [])].reverse(), + }; + const values = { note: "hi", kind: "email", address: "a@b.c" }; + const { rerender } = renderWithMantine( + , + ); + expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy(); + + rerender( + , + ); + // Still the email branch — the one the values describe — not whatever + // now sits at the old index. + expect( + (screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement) + .value, + ).toBe("email"); + expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy(); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 047502bb43..24d978dbeb 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -623,6 +623,18 @@ export function SchemaForm({ const [branchIndex, setBranchIndex] = useState( () => selectBranchIndex(branches, values) ?? 0, ); + // The alternatives themselves, as a stable key. A tool refreshed in place + // keeps its `resetKey`, so nothing else notices that the union underneath was + // reordered or rewritten — and a numeric index then points at a different + // branch than the one whose values are held, showing SMS while submitting + // email. Re-derived from the values, which is where the answer actually is. + const branchesKey = branches + .map((branch) => `${branch.label}:${branch.declaredFields.join(",")}`) + .join("|"); + useValueChange(branchesKey, () => + setBranchIndex(selectBranchIndex(branches, values) ?? 0), + ); + // A form reused for another entity can be handed a shorter union, so the // index is clamped rather than trusted — `resetKey` resets it below, but a // caller that omits it (the elicitation panels mount fresh) supplies none. From 85b030cbdea699cedaab4315fc7c985b34ecf2b1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 14:50:52 -0400 Subject: [PATCH 052/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2037=20=E2=80=94=20stale=20constants,=20inherited=20answers,?= =?UTF-8?q?=20prefixed=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The seeding effect re-applies constants as well as filling absences: an in-place schema change can move a `const` the user cannot edit, and the read-only field then displayed the new value while `values` still held the old one — which is what would be submitted. Ordinary defaults are still only ever added, so an edited field keeps what the user put there. - hasMissingIn checks own-property presence: a required argument legally named `constructor` resolved to the inherited one and read as supplied, enabling a submit the schema rejects. - The TUI keeps the declared name as a branch field's fallback title. The prefix is an internal field NAME, and buildFields labels from its map key — so the showcase displayed `__b0__address` where the schema says `address`. Signed-off-by: cliffhall --- clients/tui/__tests__/schemaToForm.test.ts | 29 +++++++++++++++ clients/tui/src/utils/schemaToForm.ts | 19 +++++++++- .../groups/SchemaForm/SchemaForm.test.tsx | 37 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 22 +++++++++-- clients/web/src/utils/jsonUtils.test.ts | 14 +++++++ clients/web/src/utils/jsonUtils.ts | 4 ++ 6 files changed, 120 insertions(+), 5 deletions(-) diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 1ced53578d..521ba528d2 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -465,6 +465,35 @@ describe("schemaToForm", () => { ]); }); + it("labels a branch field by the name the schema declared", () => { + // The prefix is an internal field name; `buildFields` falls back to its + // map key for a label, so the user would otherwise see `__b0__address`. + const form = schemaToForm(UNION, "union_tool"); + expect(form.sections[1]!.fields[1]).toMatchObject({ + name: "__b0__address", + label: "address", + }); + }); + + it("keeps a declared title ahead of the fallback", () => { + const form = schemaToForm( + { + type: "object", + anyOf: [ + { + type: "object", + properties: { a: { type: "string", title: "Street address" } }, + }, + { type: "object", properties: { b: { type: "string" } } }, + ], + }, + "titled", + ); + expect(form.sections[1]!.fields[0]).toMatchObject({ + label: "Street address", + }); + }); + it("offers a variant select listing the alternatives", () => { const form = schemaToForm(UNION, "union_tool"); expect(form.sections[0]!.fields[0]).toMatchObject({ diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 4eb4fc9f33..1c276dbdbb 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -143,6 +143,19 @@ function branchFields( return [...new Set([...own, ...sharedFieldNames(base, branches)])]; } +/** + * A property declaration carrying the name it was declared under as its + * fallback `title`, so a renamed field still displays the schema's own name. + */ +function labelled(property: unknown, name: string): unknown { + if (typeof property !== "object" || property === null) { + // JSON Schema's `true` constrains nothing, so a title-only object says the + // same thing and carries the label. + return { title: name }; + } + return { title: name, ...property }; +} + /** The `const` a property schema pins its value to, if any. */ function constOf(schema: unknown): unknown { if (typeof schema !== "object" || schema === null) return undefined; @@ -217,7 +230,11 @@ export function schemaToForm( const properties = Object.fromEntries( branchFields(base, branches, index).map((name) => [ branchFieldName(prefix, index, name), - branch.schema.properties?.[name], + // The prefix is an internal field NAME, never a label: `buildFields` + // falls back to its map key when a declaration carries no `title`, so + // without this the form would show `__b0__address` where the schema + // says `address`. + labelled(branch.schema.properties?.[name], name), ]), ); sections.push({ diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 9aa55d23c5..66a529bbcb 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2391,6 +2391,43 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(screen.getByRole("textbox", { name: /Address/ })).toBeTruthy(); }); + it("corrects a stale const when the schema changes in place", async () => { + // The user cannot edit a read-only field, so a `const` that moves under + // an unchanged `resetKey` would display the new value while the old one + // sat in `values`, waiting to be submitted. + const onChange = vi.fn(); + const pinned = (value: string): InspectorFormSchema => ({ + type: "object", + properties: { + kind: { type: "string", const: value }, + note: { type: "string", title: "Note" }, + }, + }); + const { rerender } = renderWithMantine( + , + ); + await Promise.resolve(); + onChange.mockClear(); + + rerender( + , + ); + // The constant is corrected; what the user typed is left alone. + await waitFor(() => + expect(onChange).toHaveBeenCalledWith({ kind: "sms", note: "kept" }), + ); + }); + it("renders no picker for a single-branch union but still shows its fields", () => { 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 24d978dbeb..c62641e0e8 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -36,7 +36,10 @@ import { resolveRootUnion, selectBranchIndex, } from "@inspector/core/json/rootUnion.js"; -import { collectSchemaDefaults } from "../../../utils/jsonUtils"; +import { + applySchemaConstants, + collectSchemaDefaults, +} from "../../../utils/jsonUtils"; const FieldLabel = Text.withProps({ fw: 500, @@ -751,9 +754,20 @@ export function SchemaForm({ const missing = Object.entries(collectSchemaDefaults(current)).filter( ([name]) => !Object.hasOwn(held, name), ); - if (missing.length > 0) { - report({ ...held, ...Object.fromEntries(missing) }); - } + // Constants are re-applied as well as seeded: an in-place schema change can + // move a `const` the user cannot edit — the read-only field then displays + // the new value while `values` still holds the old one, which is what would + // be submitted. Ordinary defaults are only ever *added*, so an edited field + // keeps what the user put there. + const next = applySchemaConstants( + current, + missing.length > 0 ? { ...held, ...Object.fromEntries(missing) } : held, + ); + const changed = Object.keys(next).some( + (name) => + !Object.hasOwn(held, name) || !Object.is(next[name], held[name]), + ); + if (changed) report(next); // Keyed by the entity and branch being edited, and by the fixed values // themselves: a tool refreshed in place keeps its `resetKey` and its branch // while its schema changes underneath, and a newly pinned field would diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 62d0e12318..10d7870b8a 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -601,6 +601,20 @@ describe("root composition (#2123)", () => { ).toBe(true); }); + it("does not read an inherited property as a supplied answer", () => { + // `constructor` is a legal argument name; reading it off the prototype + // would enable a submit the schema rejects. + const schema: InspectorFormSchema = { + type: "object", + // Built through `fromEntries`: in an object literal, `constructor` is + // TypeScript's own inherited member rather than a plain key. + properties: Object.fromEntries([["constructor", { type: "string" }]]), + required: ["constructor"], + }; + expect(hasMissingRequiredFields(schema, {})).toBe(true); + expect(hasMissingRequiredFields(schema, { constructor: "x" })).toBe(false); + }); + it("blocks submission while no branch is satisfied", () => { expect(hasMissingRequiredFields(UNION, {})).toBe(true); expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true); diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index 5293c6972b..f2f9a56cb2 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -352,6 +352,10 @@ function hasMissingIn( const required = Array.isArray(schema.required) ? schema.required : []; const properties = schema.properties ?? {}; return required.some((field) => { + // `hasOwn` first: an argument legally named `constructor` would otherwise + // resolve to the inherited one and read as supplied, enabling a submit the + // schema rejects. + if (!Object.hasOwn(values, field)) return true; const value = values[field]; if (value === null) { const fieldSchema = properties[field]; From ab85667deeeccc1255e9363fbc90fb65438fbcfe Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 15:20:23 -0400 Subject: [PATCH 053/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2038=20=E2=80=94=20constants=20must=20agree,=20not=20just=20?= =?UTF-8?q?types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-branch agreement check ignored `const`, so two branches pinned to `1` and `"1"` both stated no `type`, both matched the text `1`, and the first one's typed constant was sent. Textually indistinguishable constants of different types are ambiguous, and the raw string the user typed is the honest answer. Signed-off-by: cliffhall --- clients/web/src/test/core/jsonUtils.test.ts | 18 ++++++++++++++++++ core/json/jsonUtils.ts | 13 ++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/clients/web/src/test/core/jsonUtils.test.ts b/clients/web/src/test/core/jsonUtils.test.ts index 68c4190b49..29f60d560e 100644 --- a/clients/web/src/test/core/jsonUtils.test.ts +++ b/clients/web/src/test/core/jsonUtils.test.ts @@ -334,6 +334,24 @@ describe("JSON Utils", () => { }); }); + it("treats textually equal constants of different types as ambiguous (#2123)", () => { + const indistinguishable: Tool = { + name: "indistinguishable", + inputSchema: { + type: "object", + anyOf: [ + { type: "object", properties: { kind: { const: 1 }, a: {} } }, + { type: "object", properties: { kind: { const: "1" }, b: {} } }, + ], + }, + }; + // `kind=1` matches both, so neither typed constant may be assumed — + // the raw string is the honest answer. + expect(convertToolParameters(indistinguishable, { kind: "1" })).toEqual({ + kind: "1", + }); + }); + it("leaves an ambiguously typed argument uncoerced (#2123)", () => { const ambiguous: Tool = { name: "ambiguous", diff --git a/core/json/jsonUtils.ts b/core/json/jsonUtils.ts index cecce758fe..205db2ab2a 100644 --- a/core/json/jsonUtils.ts +++ b/core/json/jsonUtils.ts @@ -209,7 +209,18 @@ function coercionProperties( // first would then coerce `value=true` to `NaN`. .map((schema) => normalizeNullableUnion(schema as object)); const types = new Set(declarations.map((schema) => typeNameOf(schema))); - if (types.size === 1 && declarations.length > 0) { + // The `const` has to agree too, not just the type: `{ const: 1 }` and + // `{ const: "1" }` both state no `type` and both match the text `1`, so + // agreeing on the type alone would send whichever typed constant came + // first rather than falling back to the raw string the user typed. + const pinnedOf = (schema: unknown) => + typeof schema === "object" && schema !== null && "const" in schema + ? (schema as { const?: unknown }).const + : undefined; + const constsAgree = declarations.every((schema) => + sameJsonValue(pinnedOf(schema), pinnedOf(declarations[0])), + ); + if (types.size === 1 && constsAgree && declarations.length > 0) { Object.defineProperty(properties, name, { value: declarations[0], writable: true, From 48651956e530ada6b78e7c43fbc6ca481c00fabc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 15:37:28 -0400 Subject: [PATCH 054/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2039=20=E2=80=94=20identify=20branches=20by=20their=20schema?= =?UTF-8?q?s,=20cover=20the=20deep-link=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - branchesKey is built from the resolved alternatives themselves, not from labels and field names: two branches can share both while pinning different discriminators or typing a field differently, and a reorder of those went unnoticed — the form then displayed one branch and submitted another. - InspectorView gains a deep-link test with root-union appArgs: they name the second branch, and the assertions pin the picker, the read-only discriminator, THAT branch's default (not the first branch's), and the absence of the other branch's field. The old shallow spread fails it. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.tsx | 9 ++- .../InspectorView/InspectorView.test.tsx | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index c62641e0e8..9fbb3d6945 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -631,9 +631,12 @@ export function SchemaForm({ // reordered or rewritten — and a numeric index then points at a different // branch than the one whose values are held, showing SMS while submitting // email. Re-derived from the values, which is where the answer actually is. - const branchesKey = branches - .map((branch) => `${branch.label}:${branch.declaredFields.join(",")}`) - .join("|"); + // The whole resolved alternative, not just its label and field names: two + // branches can share both while pinning different discriminators or typing a + // field differently, and a reorder of those would otherwise go unnoticed. + const branchesKey = serializeJson( + branches.map((branch) => [branch.label, branch.schema]), + ); useValueChange(branchesKey, () => setBranchIndex(selectBranchIndex(branches, values) ?? 0), ); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index d7e2e5c10a..fc34c4bbaf 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -867,6 +867,70 @@ describe("InspectorView", () => { expect(screen.getByDisplayValue("retention")).toBeInTheDocument(); }); + it("deep-link appArgs select their root-union branch and keep its defaults (#2123)", async () => { + const unionAppTool: Tool = { + name: "notify", + title: "Notify", + inputSchema: { + type: "object", + properties: { note: { type: "string" } }, + anyOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "email" }, + address: { type: "string" }, + retries: { type: "number", default: 1 }, + }, + required: ["kind", "address"], + }, + { + type: "object", + properties: { + kind: { type: "string", const: "sms" }, + phone: { type: "string" }, + retries: { type: "number", default: 3 }, + }, + required: ["kind", "phone"], + }, + ], + }, + _meta: { ui: { resourceUri: "ui://apps/notify" } }, + }; + renderWithMantine( + , + ); + // The picker opens on the branch the args describe… + expect(await screen.findByDisplayValue("555-0100")).toBeInTheDocument(); + // Twice over: the Variant picker names the branch, and the read-only + // discriminator carries the value that will be submitted. + expect(screen.getAllByDisplayValue("sms")).toHaveLength(2); + // …with THAT branch's default, not the first branch's `1`. + expect(screen.getByDisplayValue("3")).toBeInTheDocument(); + // …and nothing from the branch this call is not making. + expect(screen.queryByRole("textbox", { name: /address/i })).toBeNull(); + }); + it("ignores a deep-link openApp whose tool is not an app (no tab switch)", async () => { renderWithMantine( Date: Wed, 26 Aug 2026 15:49:54 -0400 Subject: [PATCH 055/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2040=20=E2=80=94=20a=20required=20name=20counts=20as=20a=20f?= =?UTF-8?q?ield?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema may require a property it never declares: `{ type: "object", required: ["token"] }` is legal and the tool plainly takes an argument. declaresAnyFields counted only `properties`, so an App tool shaped that way — including nested under a composition — was reported input-free and auto-invoked with `{}`. Signed-off-by: cliffhall --- clients/web/src/test/core/rootUnion.test.ts | 14 ++++++++++++++ clients/web/src/utils/toolUtils.test.ts | 6 ++++++ core/json/rootUnion.ts | 5 +++++ 3 files changed, 25 insertions(+) diff --git a/clients/web/src/test/core/rootUnion.test.ts b/clients/web/src/test/core/rootUnion.test.ts index 6910056121..5a747ed5f7 100644 --- a/clients/web/src/test/core/rootUnion.test.ts +++ b/clients/web/src/test/core/rootUnion.test.ts @@ -901,6 +901,20 @@ describe("resolveRootUnion", () => { ).toBe(true); }); + it("counts a required name a schema never declares", () => { + // Legal, and the tool plainly takes an argument — an App tool shaped this + // way must ask rather than being auto-invoked with `{}`. + expect(declaresAnyFields({ type: "object", required: ["token"] })).toBe( + true, + ); + expect( + declaresAnyFields({ + type: "object", + allOf: [{ type: "object", required: ["token"] }], + }), + ).toBe(true); + }); + it("reports none for a bare object schema", () => { expect(declaresAnyFields({ type: "object" })).toBe(false); expect(declaresAnyFields(undefined)).toBe(false); diff --git a/clients/web/src/utils/toolUtils.test.ts b/clients/web/src/utils/toolUtils.test.ts index 07c6446f68..efe0211e8e 100644 --- a/clients/web/src/utils/toolUtils.test.ts +++ b/clients/web/src/utils/toolUtils.test.ts @@ -145,6 +145,12 @@ describe("hasInputFields with root composition (#2123)", () => { ).toBe(true); }); + it("counts a required name the schema never declares", () => { + expect(hasInputFields(tool({ type: "object", required: ["token"] }))).toBe( + true, + ); + }); + it("still reports no fields for a bare object schema", () => { expect(hasInputFields(tool({ type: "object" }))).toBe(false); }); diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index b480f4e821..279cd1a7ad 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -498,6 +498,11 @@ export function declaresAnyFields( ): boolean { if (schema === undefined) return false; if (Object.keys(propertiesOf(schema)).length > 0) return true; + // A schema may require a name it never declares — `{ required: ["token"] }` + // is a legal object schema, and the tool plainly takes an argument. Counting + // it here is what stops an App tool with that shape being auto-invoked with + // `{}` for want of a `properties` map. + if (requiredOf(schema).length > 0) return true; // A `$ref`'s shape is unknown rather than empty, so it counts. Reporting "no // fields" for `anyOf: [{ $ref: … }, { $ref: … }]` would auto-invoke an App // tool with `{}` on the strength of something never read. From b0b252a5c02e28ee29f48d67ee00fdcb8bf243d3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 16:08:24 -0400 Subject: [PATCH 056/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2041=20=E2=80=94=20const=20does=20not=20demand=20a=20propert?= =?UTF-8?q?y,=20required-only=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `const` constrains a property that is PRESENT; it neither requires the property nor acts as a default. It is now supplied automatically only where the schema also requires the field — in `collectSchemaDefaults`, `applySchemaConstants` and the TUI's initial values alike — so an optional `dryRun: { const: true }` no longer turns a valid `{}` call into one that asks the server to do something. A value that IS present is still corrected to the constant, whatever supplied it, and the field stays read-only. - Offerability is judged on the base+member merge rather than the raw member, so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over root-declared properties is offered. It was declined, which left the gate checking the base alone and accepting `{}` — which that schema rejects. - The TUI's branch sections keep the schema's own `required` for the seeding decision while still rendering every branch field non-required, which is what keeps a static form submittable. Signed-off-by: cliffhall --- README.md | 2 +- clients/tui/__tests__/schemaToForm.test.ts | 21 ++++++ clients/tui/src/utils/schemaToForm.ts | 69 ++++++++++++++++--- .../groups/SchemaForm/SchemaForm.test.tsx | 2 + clients/web/src/test/core/rootUnion.test.ts | 18 +++++ clients/web/src/utils/jsonUtils.test.ts | 23 ++++++- clients/web/src/utils/jsonUtils.ts | 32 +++++++-- core/json/rootUnion.ts | 22 ++++-- 8 files changed, 167 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 139272c3da..1001d9eed7 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ The **TUI** had the same gap and is worth checking against the same server (`--t The 2026-07-28 revision makes this shape explicitly legal: `type: "object"` is required at the root, and beyond that "any JSON Schema 2020-12 keyword may appear alongside `type`, including composition keywords (`oneOf`, `anyOf`, `allOf`, `not`)". -Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. +Open the Tools tab and select `echo`. Above the fields is a **Variant** picker listing the union's alternatives — labelled from each branch's `title`, else its discriminator `const`, else its position — and choosing one swaps in that branch's fields with the discriminator already filled in. A field the schema pins with `const` renders read-only, and is filled in automatically only where the schema also **requires** it: `const` constrains a value that is present rather than demanding one, so an optional pinned field stays omittable. The two tools show the two halves of the old behavior. On the broken build `echo` rendered its root `message` and **nothing from either branch**, so it could only ever be called with half its arguments; `get_weather`, whose fields live entirely on its `oneOf`, rendered **nothing but the Execute Tool button** — no picker, no fields, not even the raw-JSON editor a union-typed _property_ falls back to ([#2123](https://github.com/modelcontextprotocol/inspector/issues/2123)). diff --git a/clients/tui/__tests__/schemaToForm.test.ts b/clients/tui/__tests__/schemaToForm.test.ts index 521ba528d2..b245132e97 100644 --- a/clients/tui/__tests__/schemaToForm.test.ts +++ b/clients/tui/__tests__/schemaToForm.test.ts @@ -555,6 +555,7 @@ describe("schemaToForm", () => { { type: "object", properties: { v: { type: "string", const: "a", default: "b" } }, + required: ["v"], }, "const_tool", ); @@ -565,6 +566,26 @@ describe("schemaToForm", () => { }); }); + it("leaves an optional const unfilled", () => { + // `const` constrains a present value; it does not require the property + // or act as a default, so an optional one must stay omittable. + const form = schemaToForm( + { + type: "object", + properties: { dryRun: { type: "boolean", const: true } }, + }, + "optional_const", + ); + const field = form.sections[0]!.fields[0] as { + initialValue?: unknown; + options?: unknown[]; + }; + expect(field.initialValue).toBeUndefined(); + // The single option is still offered to a user who wants it. + expect(field.options).toEqual([{ label: "true", value: "true" }]); + expect(decodeFormValues({ type: "object" }, {})).toEqual({}); + }); + it("renders a branch's specialization of a root property in its section", () => { const form = schemaToForm( { diff --git a/clients/tui/src/utils/schemaToForm.ts b/clients/tui/src/utils/schemaToForm.ts index 1c276dbdbb..a86f3d18f8 100644 --- a/clients/tui/src/utils/schemaToForm.ts +++ b/clients/tui/src/utils/schemaToForm.ts @@ -156,6 +156,13 @@ function labelled(property: unknown, name: string): unknown { return { title: name, ...property }; } +/** A schema's `required`, as the list of strings it should be. */ +function requiredOf(schema: { required?: unknown }): string[] { + return Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : []; +} + /** The `const` a property schema pins its value to, if any. */ function constOf(schema: unknown): unknown { if (typeof schema !== "object" || schema === null) return undefined; @@ -227,6 +234,9 @@ export function schemaToForm( // says: only one alternative applies to a call, so requiring them would build // a form that can never be submitted. branches.forEach((branch, index) => { + const byRealName = Object.fromEntries( + branchFields(base, branches, index).map((name) => [name, true]), + ); const properties = Object.fromEntries( branchFields(base, branches, index).map((name) => [ branchFieldName(prefix, index, name), @@ -239,7 +249,17 @@ export function schemaToForm( ); sections.push({ title: branch.label, - fields: buildFields({ properties }), + fields: buildFields( + { + properties, + // Under their prefixed names, so the constant seeding below can tell + // which of this branch's fields the schema actually requires. + required: requiredOf(branch.schema) + .filter((name) => Object.hasOwn(byRealName, name)) + .map((name) => branchFieldName(prefix, index, name)), + }, + { optional: true }, + ), }); }); @@ -270,7 +290,7 @@ export function decodeFormValues( ): Record { const { base, branches } = resolveRootUnion(schema ?? {}); if (branches.length === 0) { - return applyConstants(base.properties ?? {}, values); + return applyConstants(base.properties ?? {}, values, requiredOf(base)); } const { variant, prefix } = generatedNames(base, branches); @@ -298,7 +318,11 @@ export function decodeFormValues( .filter(([, value]) => value !== undefined), ]); /* v8 ignore next -- an offerable branch always carries properties */ - return applyConstants(branch.schema.properties ?? {}, decoded); + return applyConstants( + branch.schema.properties ?? {}, + decoded, + requiredOf(branch.schema), + ); } /** @@ -320,13 +344,24 @@ function selectedBranchIndex( : 0; } -/** Overwrite every `const`-pinned field with the value its schema fixes. */ +/** + * Overwrite every `const`-pinned field with the value its schema fixes — + * where the field is PRESENT, or where the schema also requires it. + * + * `const` constrains a present value; it neither requires the property nor + * acts as a default. Restoring an optional one the user left blank would send + * it on every call. + */ function applyConstants( properties: Record, values: Record, + required: string[], ): Record { const pinned = Object.entries(properties).filter( - ([, schema]) => constOf(schema) !== undefined, + ([name, schema]) => + constOf(schema) !== undefined && + (required.includes(name) || + (Object.hasOwn(values, name) && values[name] !== undefined)), ); if (pinned.length === 0) return values; return Object.fromEntries([ @@ -335,8 +370,19 @@ function applyConstants( ]); } -/** Build the ink-form fields for one already-flattened object schema. */ -function buildFields(schema: JsonSchemaObject): FormField[] { +/** + * Build the ink-form fields for one already-flattened object schema. + * + * `optional` renders every field non-required whatever the schema says, for a + * branch section: only one alternative applies to a call, so demanding all of + * them would build a form that can never be submitted. The schema's own + * `required` is still read — it decides which constants are pre-filled, and + * `missingRequiredFields` enforces the real requirement at submit. + */ +function buildFields( + schema: JsonSchemaObject, + { optional = false }: { optional?: boolean } = {}, +): FormField[] { const fields: FormField[] = []; const properties = schema.properties || {}; // `Array.isArray`, not `|| []`: a nonconforming server can send @@ -360,7 +406,7 @@ function buildFields(schema: JsonSchemaObject): FormField[] { const baseField = { name: key, label: property.title || key, - required: required.includes(key), + required: optional ? false : required.includes(key), }; let field: FormField; @@ -374,13 +420,18 @@ function buildFields(schema: JsonSchemaObject): FormField[] { fields.push({ type: "select", ...baseField, + // Only a REQUIRED constant is pre-filled. `const` constrains a present + // value; it neither requires the property nor acts as a default, so an + // optional `dryRun: { const: true }` must stay omittable rather than + // being sent on every call. The single option is still there for a user + // who wants it. + ...(required.includes(key) ? { initialValue: String(pinned) } : {}), // Never required: the one option may legitimately be the empty string, // which ink-form's required gate can never accept — submission would // not even reach `decodeFormValues`, which reapplies the constant. The // value is fixed by the schema, and `missingRequiredFields` still // validates the decoded call. required: false, - initialValue: String(pinned), options: [{ label: String(pinned), value: String(pinned) }], } as FormField); continue; diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx index 66a529bbcb..b0c2443867 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2344,6 +2344,7 @@ describe("SchemaForm multiline strings (#2042)", () => { a: { type: "string", title: "A" }, version: { type: "string", const: "2" }, }, + required: ["version"], }} values={{}} onChange={onChange} @@ -2402,6 +2403,7 @@ describe("SchemaForm multiline strings (#2042)", () => { kind: { type: "string", const: value }, note: { type: "string", title: "Note" }, }, + required: ["kind"], }); const { rerender } = renderWithMantine( { }); }); + it("offers a branch that only adds required names over the root's fields", () => { + // `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` is an + // ordinary way to say "one of these two". Judging the member on its own + // properties declined it, leaving the gate checking the base alone and + // accepting `{}` — which the schema rejects. + const { branches } = resolveRootUnion({ + type: "object", + properties: { email: { type: "string" }, phone: { type: "string" } }, + anyOf: [ + { type: "object", required: ["email"] }, + { type: "object", required: ["phone"] }, + ], + }); + expect(branches).toHaveLength(2); + expect(branches[0].schema.required).toEqual(["email"]); + expect(branches[1].schema.required).toEqual(["phone"]); + }); + describe("oneOf exclusivity", () => { it("offers a discriminated oneOf", () => { expect( diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index 10d7870b8a..f7de80921e 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -375,15 +375,32 @@ describe("root composition (#2123)", () => { expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone"); }); - it("seeds a const property on an ordinary schema too", () => { + it("seeds a required const property on an ordinary schema too", () => { expect( collectSchemaDefaults({ type: "object", properties: { version: { type: "string", const: "1" } }, + required: ["version"], }), ).toEqual({ version: "1" }); }); + it("leaves an optional const out", () => { + // `const` constrains a present value; it does not require the property or + // act as a default, so seeding it would turn a valid `{}` call into one + // that asks the server to do something. + const optional: InspectorFormSchema = { + type: "object", + properties: { dryRun: { type: "boolean", const: true } }, + }; + expect(collectSchemaDefaults(optional)).toEqual({}); + expect(applySchemaConstants(optional, {})).toEqual({}); + // …but a value that IS present must be the one the schema fixes. + expect(applySchemaConstants(optional, { dryRun: false })).toEqual({ + dryRun: true, + }); + }); + it("prefers a const over a conflicting default", () => { // `default` is an annotation, not a constraint, so a schema may advertise // one its own `const` rejects — seeding it would submit an invalid value @@ -394,6 +411,7 @@ describe("root composition (#2123)", () => { properties: { v: { type: "string", const: "a", default: "b" }, }, + required: ["v"], }), ).toEqual({ v: "a" }); }); @@ -454,6 +472,7 @@ describe("root composition (#2123)", () => { properties: Object.fromEntries([ ["__proto__", { type: "string", const: "kept" }], ]), + required: ["__proto__"], }); expect(Object.hasOwn(seeded, "__proto__")).toBe(true); }); @@ -474,6 +493,7 @@ describe("root composition (#2123)", () => { kind: { type: "string", const: "email" }, address: { type: "string" }, }, + required: ["kind"], }, { type: "object", @@ -481,6 +501,7 @@ describe("root composition (#2123)", () => { kind: { type: "string", const: "sms" }, phone: { type: "string" }, }, + required: ["kind"], }, ], }, diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index f2f9a56cb2..24a31e91d4 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -145,7 +145,17 @@ export function collectSchemaDefaults( // to a form showing the branch the values actually identify. const { base, branches } = resolveRootUnion(schema); const selected = selectBranchIndex(branches, knownValues) ?? 0; - const properties = (branches[selected]?.schema ?? base).properties ?? {}; + const effective = branches[selected]?.schema ?? base; + const properties = effective.properties ?? {}; + // `const` constrains a property only when it is PRESENT — it neither requires + // the property nor acts as a default. So it is supplied automatically for a + // REQUIRED field, whose only submittable value was never in doubt and which + // the form renders read-only, and left alone otherwise: seeding an optional + // `dryRun: { const: true }` would turn a valid `{}` call into one that asks + // the server to do something. + const requiredNames = Array.isArray(effective.required) + ? effective.required + : []; const result: Record = {}; // `properties` is a JSON record, so `__proto__` is a legal field name that a // plain assignment would drop into the legacy prototype setter rather than @@ -164,10 +174,10 @@ export function collectSchemaDefaults( // 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.const !== undefined) { - // `const` is a one-value enumeration, so the only submittable value is - // already known — seeding it spares the user hand-typing a discriminator - // the schema has fixed (#2123). + if (fieldSchema.const !== undefined && requiredNames.includes(fieldName)) { + // `const` is a one-value enumeration, so a required field's only + // submittable value is already known — seeding it spares the user + // hand-typing a discriminator the schema has fixed (#2123). // // It outranks `default`, which JSON Schema defines as an annotation // rather than a constraint: a schema may advertise a default its own @@ -267,13 +277,21 @@ export function applySchemaConstants( ): Record { const { base, branches } = resolveRootUnion(schema); const selected = selectBranchIndex(branches, values) ?? 0; - const properties = (branches[selected]?.schema ?? base).properties ?? {}; + const effective = branches[selected]?.schema ?? base; + const properties = effective.properties ?? {}; + const required = Array.isArray(effective.required) ? effective.required : []; const corrections: [string, unknown][] = []; for (const [name, rawSchema] of Object.entries(properties)) { const fieldSchema = normalizeNullableUnion(rawSchema); if (fieldSchema.const !== undefined) { - corrections.push([name, fieldSchema.const]); + // Corrected where the property is PRESENT — whatever supplied it, the + // schema fixes the value — and inserted only where the schema also + // requires it. An optional `const` the caller left out stays out: + // `const` constrains a present value, it does not demand one. + if (Object.hasOwn(values, name) || required.includes(name)) { + corrections.push([name, fieldSchema.const]); + } continue; } // Recurse: a nested object renders its own read-only fields, and the diff --git a/core/json/rootUnion.ts b/core/json/rootUnion.ts index 279cd1a7ad..7921824cf8 100644 --- a/core/json/rootUnion.ts +++ b/core/json/rootUnion.ts @@ -175,9 +175,23 @@ function propertiesOf(schema: RootUnionSchema): Record { * `{ type: "string", properties: {…} }` member can never match — rendering it * as a fillable form would offer a call that cannot be valid. */ -function isOfferable(branch: RootUnionSchema): boolean { - if (!hasReadableProperties(branch) || !admitsObject(branch)) return false; - const properties = Object.values(propertiesOf(branch)); +function isOfferable( + branch: RootUnionSchema, + merged: RootUnionSchema, +): boolean { + // The MEMBER decides whether the shape is readable at all… + if (branch.properties !== undefined && !hasReadableProperties(branch)) { + return false; + } + if (!admitsObject(branch)) return false; + + // …and the MERGE decides whether there is anything to render. A member may + // legitimately declare no properties of its own and only add `required` over + // names the root already declares — `anyOf: [{ required: ["email"] }, …]` is + // a perfectly ordinary way to say "one of these two" — and judging it on its + // own properties would decline it, leaving the gate checking the base alone + // and accepting `{}`, which the schema rejects. + const properties = Object.values(propertiesOf(merged)); return ( properties.length > 0 && // Every value has to be something a renderer can read AND something a @@ -679,7 +693,7 @@ export function resolveRootUnion( branches.some( (branch) => branch === null || - !isOfferable(branch) || + !isOfferable(branch, mergeBranch(base, branch)) || // The same faithfulness test the `allOf` fold applies: a member // carrying a constraint the merge does not copy — a nested `allOf`, a // `not`, a `$ref` — would have it erased along with the union keyword, From e0ecf58df3dba2be56ba6a619fa4a6f4cff09914 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 16:25:57 -0400 Subject: [PATCH 057/213] =?UTF-8?q?fix:=20address=20Copilot=20review=20rou?= =?UTF-8?q?nd=2042=20=E2=80=94=20let=20an=20optional=20const=20be=20opted?= =?UTF-8?q?=20into?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making every pinned field read-only left an optional one unreachable: the schema is equally happy with or without it, but the form displayed a value the user could neither send nor edit. An optional `const` is a yes/no rather than a fixed answer, so it renders as its single choice, clearable — opting in sends the schema's own typed value, opting out leaves the property absent. A required one is still displayed read-only, since there is nothing to decide. Signed-off-by: cliffhall --- .../groups/SchemaForm/SchemaForm.test.tsx | 42 +++++++++++++++++++ .../groups/SchemaForm/SchemaForm.tsx | 42 ++++++++++++++++--- 2 files changed, 78 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 b0c2443867..826f759d64 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.test.tsx @@ -2054,6 +2054,46 @@ describe("SchemaForm multiline strings (#2042)", () => { expect(kind.value).toBe("email"); }); + it("lets an optional const be opted into and left out", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const optional: InspectorFormSchema = { + type: "object", + properties: { dryRun: { type: "boolean", const: true, title: "Dry" } }, + }; + function Host() { + const [values, setValues] = useState>({}); + return ( + { + setValues(next); + onChange(next); + }} + /> + ); + } + renderWithMantine(); + + const field = screen.getByRole("textbox", { + name: /Dry/, + }) as HTMLInputElement; + // Not supplied to begin with — `const` does not demand the property. + expect(field.value).toBe(""); + + await user.click(field); + await user.click(screen.getByRole("option", { name: "true" })); + // Opting in sends the schema's own typed value, not the label. + expect(onChange).toHaveBeenCalledWith({ dryRun: true }); + + // Mantine marks its combobox clear button `aria-hidden` (mouse-only, + // `tabIndex={-1}`), so it is only reachable with `hidden: true`. + await user.click(screen.getByRole("button", { hidden: true })); + // …and opting back out leaves the property absent from the call. + expect(onChange).toHaveBeenLastCalledWith({ dryRun: undefined }); + }); + it("renders a non-string constant read-only too", () => { // Reached before the number/boolean dispatch, so neither offers a value // the `const` forbids. @@ -2065,6 +2105,7 @@ describe("SchemaForm multiline strings (#2042)", () => { n: { type: "number", const: 7, title: "N" }, b: { type: "boolean", const: true, title: "B" }, }, + required: ["n", "b"], }} values={{}} onChange={vi.fn()} @@ -2093,6 +2134,7 @@ describe("SchemaForm multiline strings (#2042)", () => { title: "Mode", }, }, + required: ["mode"], }} values={{}} onChange={vi.fn()} diff --git a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx index 9fbb3d6945..0f1468a32f 100644 --- a/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx +++ b/clients/web/src/components/groups/SchemaForm/SchemaForm.tsx @@ -877,19 +877,49 @@ export function SchemaForm({ // constant keeps its type on the wire however it is shown here. if (fieldSchema.const !== undefined) { const constValue = fieldSchema.const; + const constText = + typeof constValue === "string" ? constValue : serializeJson(constValue); + + // An OPTIONAL pinned field is a yes/no, not a fixed answer: `const` + // constrains the value if the property is there, and the schema is + // equally happy without it. So it gets the one choice it has, clearable — + // the user can opt in or leave it out, and cannot type anything else. + if (!isRequired) { + return ( +